Files
AILang/crates/ail/tests/e2e.rs
T
Brummel 26fb3459d8 GREEN: io/print_str byte-faithful via @fputs(@stdout) — closes #29
The runtime print path now writes exactly the bytes of its
argument with no implicit trailing newline. `io/print_str` is
byte-faithful; authors who want a newline emit `(do io/print_str
"\n")` themselves.

## Codegen

`crates/ailang-codegen/src/lib.rs`:
- Module preamble: `@puts(ptr)` → `@fputs(ptr, ptr)` plus
  `@stdout = external global ptr` (libc's `FILE *stdout`).
- Effect-op lowering for `io/print_str`: emit
  `getelementptr +8` then `load ptr, ptr @stdout` then
  `call/tail call i32 @fputs(ptr bytes, ptr fp)`. Identical
  bytes-pointer GEP, distinct sink.
- The pinned IR-shape test renames from
  `print_str_calls_puts_with_bytes_pointer` to
  `print_str_calls_fputs_with_bytes_pointer_and_stdout` and now
  asserts: bytes-GEP present, stdout-load present, both module-
  preamble declarations present, and no `@puts(` call anywhere
  in the emitted IR.

## Why this shape, and not the alternatives

- *Rename `io/print_str` to `io/println_str` (issue #29 option 2)*
  — kept the auto-newline, just relabelled it. AILang's design
  bias is explicit-over-implicit (CLAUDE.md: implicit conversions
  cut). Auto-newline is a hidden runtime augmentation; the rename
  would have preserved it. Rejected.
- *Append `\n` inside the polymorphic `print` (Show-mediated)
  function in `examples/prelude.ail`* — would have been a one-line
  fix. Rejected: `print` is the Show-mediated formatter, not a
  newline emitter; baking a newline into it would have re-imposed
  the same implicit-augmentation problem one layer up, breaking
  callers that legitimately want pure-bytes output.

## Fixture / test sweep

30 `.ail` fixtures whose owning tests asserted line-separated
stdout now emit explicit `(do io/print_str "\n")` after each
print. Tests that asserted on multi-line stdout (`show_print_e2e`,
`floats_e2e`, `str_concat_e2e`, `eq_ord_e2e`, several `print_*`
smoke tests) had their fixtures sweetened the same way; assertions
themselves remain the canonical observable output. Fixtures that
never relied on the newline (no test ever read the absence of one)
were left untouched.

## Migrated metadata

- `crates/ailang-core/tests/hash_pin.rs`: the `ordering_match::main`
  canonical hash is refreshed (`b65a7f834703ffb4` →
  `8ed47b4062ce00f5`). The comment now names both successive
  corpus migrations honestly: the per-type-print-retirement (which
  moved `(do io/print_int x)` to `(app print x)`) AND this
  fputs swap (which wrapped that with `(seq ... (do io/print_str
  "\n"))`).
- `design/contracts/str-abi.md`: the consumer-ABI table now lists
  `@fputs` as the print sink. A prose paragraph documents the
  byte-faithful semantics and references this issue.
- `examples/ordering_match.prose.txt`: regenerated from the
  updated `ordering_match.ail`.
- `crates/ail/tests/snapshots/{hello,sum,max3,list,ws_main}.ll`:
  IR snapshots regenerated via `UPDATE_SNAPSHOTS=1`.
- Stale `@puts` comments in `runtime/str.c`,
  `crates/ail/tests/{e2e,show_print_e2e,print_no_leak_pin}.rs`
  replaced with `@fputs`.

## Verification

- `cargo test -p ail --test print_str_no_auto_newline_e2e` — both
  RED tests from commit c8ecfa3 now pass.
- `cargo test --workspace` — 90 test groups GREEN, 0 failures.
- `cargo build --workspace` — GREEN.
- No new clippy lints (24 warnings pre-existing in
  `crates/ailang-core/src/lib.rs:129`).
- Stats: `bench/orchestrator-stats/2026-05-21-iter-bugfix-print-
  str-fputs.json` — 1/1 tasks, 0 re-loops, 0 review loops.

## Empirical evidence cited

Caught in the 2026-05-21 Qwen3-Coder naming-A/B run
(`experiments/2026-05-21-naming-ab/runs/r1/`): every cohort wrote
`(do io/print_str "...\n")` with explicit `\n` and got doubled
newlines, failing the `t3_main_prints` stdout match across all
three cohorts. The empirical LLM-natural form already assumes the
new (post-this-commit) semantics — confirming the
feature-acceptance test in CLAUDE.md.

closes #29
2026-05-21 12:22:45 +02:00

2779 lines
117 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! End-to-end test: load example module, compile to binary, run it.
//!
//! This test guards the most important property of the entire pipeline:
//! AST → typecheck → LLVM IR → clang → binary → correct stdout.
use std::path::Path;
use std::process::Command;
fn ail_bin() -> &'static str {
env!("CARGO_BIN_EXE_ail")
}
fn build_and_run(example: &str) -> String {
// Workspace root is two levels above the crate manifest.
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
let src = workspace.join("examples").join(example);
let tmp = std::env::temp_dir().join(format!(
"ailang_e2e_{}_{}",
example.replace('.', "_"),
std::process::id()
));
std::fs::create_dir_all(&tmp).unwrap();
let out = tmp.join("bin");
let status = Command::new(ail_bin())
.args(["build", src.to_str().unwrap(), "-o"])
.arg(&out)
.status()
.expect("ail build failed to run");
assert!(status.success(), "ail build failed for {example}");
let output = Command::new(&out).output().expect("execute binary");
assert!(
output.status.success(),
"binary {} exited non-zero",
out.display()
);
String::from_utf8(output.stdout).expect("stdout utf8")
}
/// build with an explicit `--alloc=<alloc>` and run.
/// Mirrors [`build_and_run`] but threads the allocator selector through
/// to `ail build`. Used by the alloc-equivalence tests that assert the
/// `gc` and `rc` paths produce byte-identical stdout.
fn build_and_run_with_alloc(example: &str, alloc: &str) -> String {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
let src = workspace.join("examples").join(example);
let tmp = std::env::temp_dir().join(format!(
"ailang_e2e_alloc_{}_{}_{}",
alloc,
example.replace('.', "_"),
std::process::id()
));
std::fs::create_dir_all(&tmp).unwrap();
let out = tmp.join("bin");
let status = Command::new(ail_bin())
.args([
"build",
src.to_str().unwrap(),
&format!("--alloc={alloc}"),
"-o",
])
.arg(&out)
.status()
.expect("ail build failed to run");
assert!(
status.success(),
"ail build --alloc={alloc} failed for {example}"
);
let output = Command::new(&out).output().expect("execute binary");
assert!(
output.status.success(),
"binary {} (--alloc={alloc}) exited non-zero",
out.display()
);
String::from_utf8(output.stdout).expect("stdout utf8")
}
#[test]
fn sum_1_to_10_is_55() {
let stdout = build_and_run("sum.ail");
assert_eq!(stdout.trim(), "55");
}
#[test]
fn loop_recur_sum_to_runs_to_value() {
let stdout = build_and_run("loop_sum_to_run.ail");
assert_eq!(stdout.trim(), "55");
}
#[test]
fn loop_recur_deep_n_safe_by_construction() {
// 1..1_000_000 summed via the loop back-edge (no stack growth);
// a hand-written non-tail self-recursion to this depth would
// overflow. sum 1..1_000_000 = 500000500000 (fits i64).
let stdout = build_and_run("loop_sum_to_deep.ail");
assert_eq!(stdout.trim(), "500000500000");
}
#[test]
fn loop_recur_infinite_loop_compiles() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
let src = workspace.join("examples").join("loop_forever_build.ail");
let tmp = std::env::temp_dir().join(format!(
"ailang_e2e_loopforever_{}",
std::process::id()
));
std::fs::create_dir_all(&tmp).unwrap();
let out = tmp.join("bin");
let status = Command::new(ail_bin())
.args(["build", src.to_str().unwrap(), "-o"])
.arg(&out)
.status()
.expect("ail build failed to run");
assert!(
status.success(),
"ail build must succeed on an infinite loop (typechecks AND compiles, no termination claim)"
);
}
/// Guards block tracking in codegen: max3 has nested `if`s, and wrong
/// phi block tracking would produce a wrong result here.
#[test]
fn max3_picks_largest() {
let stdout = build_and_run("max3.ail");
assert_eq!(stdout.trim(), "17");
}
#[test]
fn hello_world_str_lit() {
let stdout = build_and_run("hello.ail");
assert_eq!(stdout.trim(), "Hello, AILang.");
}
/// Guards ADT codegen + match: recursive list, sum_list via match on Cons/Nil.
#[test]
fn list_sum_via_match() {
let stdout = build_and_run("list.ail");
assert_eq!(stdout.trim(), "42");
}
/// first-class function references — `apply(inc, 41)` must
/// produce 42, exercising fn-typed parameters and indirect call.
#[test]
fn higher_order_apply_inc() {
let stdout = build_and_run("hof.ail");
assert_eq!(stdout.trim(), "42");
}
/// lambdas with capture. `let n = 3 in apply(\x. x + n, 39)`
/// must print 42 — the lambda captures `n` from the enclosing `let`,
/// the env struct is malloc'd, the closure pair is passed to `apply`,
/// and apply's indirect call unpacks both halves.
#[test]
fn closure_captures_let_n() {
let stdout = build_and_run("closure.ail");
assert_eq!(stdout.trim(), "42");
}
/// a non-trivial program that combines ADTs, recursion,
/// closure-without-capture, fn-typed parameters, IO effects, and `let`-
/// sequencing of effectful sub-expressions in a pattern-match arm.
/// `map (\x. x * 2)` over `[1, 2, 3]` then print each element.
#[test]
fn list_map_doubles_then_prints() {
let stdout = build_and_run("list_map.ail");
let lines: Vec<&str> = stdout.lines().collect();
assert_eq!(lines, vec!["2", "4", "6"]);
}
/// end-to-end exercise of parameterised ADTs through a
/// polymorphic higher-order fn. `data List a` plus
/// `map : forall a b. ((a) -> b, List<a>) -> List<b>` recursive,
/// instantiated at `(Int, Int)`. Guards: forall with two type vars +
/// recursive call inside its own body + ctor construction with
/// substituted field types + match-arm bindings flowing into an
/// effectful tail call.
#[test]
fn list_map_poly_inc_then_prints() {
let stdout = build_and_run("list_map_poly.ail");
let lines: Vec<&str> = stdout.lines().collect();
assert_eq!(lines, vec!["2", "3", "4"]);
}
/// per-fn arena via stack `alloca` for non-escaping
/// allocations. The fixture's `peek` and `count` fns each build a
/// `Box(_)` whose payload is dropped via a wildcard pattern; the
/// allocation is fully consumed locally and never flows to a fn
/// arg, ctor field, or the fn return. Escape analysis must flag
/// these allocations as non-escaping; codegen must lower them with
/// `alloca` instead of the runtime allocator. Two assertions:
/// (1) stdout matches the documented expected outputs;
/// (2) the emitted IR contains at least one `alloca i8, i64 16`
/// for the Box and zero `@ailang_rc_alloc` calls inside `peek`/`count`.
#[test]
fn iter17a_local_box_alloca() {
let stdout = build_and_run("escape_local_demo.ail");
assert_eq!(
stdout.lines().collect::<Vec<_>>(),
vec!["42", "42", "5", "0"],
"escape_local_demo stdout drift"
);
// IR check: peek and count should each contain `alloca` and no
// runtime-allocator call. Emit, then split into per-fn bodies and inspect.
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
let src = workspace
.join("examples")
.join("escape_local_demo.ail");
let tmp = std::env::temp_dir().join(format!(
"ailang_iter17a_alloca_{}",
std::process::id()
));
std::fs::create_dir_all(&tmp).unwrap();
let out_ll = tmp.join("escape_local_demo.ll");
let status = Command::new(ail_bin())
.args(["emit-ir", src.to_str().unwrap(), "-o"])
.arg(&out_ll)
.status()
.expect("ail emit-ir failed to run");
assert!(status.success(), "ail emit-ir failed");
let ir = std::fs::read_to_string(&out_ll).expect("read emitted IR");
for fn_name in ["peek", "count"] {
let header = format!("@ail_escape_local_demo_{fn_name}(");
let start = ir
.find(&header)
.unwrap_or_else(|| panic!("fn {fn_name} not found in IR"));
let after_header = &ir[start..];
let end = after_header
.find("\n}\n")
.expect("fn body terminator not found");
let body = &after_header[..end];
assert!(
body.contains("alloca i8, i64 16"),
"fn {fn_name} should contain `alloca i8, i64 16`; body:\n{body}"
);
assert!(
!body.contains("@ailang_rc_alloc"),
"fn {fn_name} should NOT contain `@ailang_rc_alloc` (allocation \
must be alloca'd); body:\n{body}"
);
}
}
/// `tail: true` annotation on `print_list`'s recursive
/// call must reach LLVM as a `musttail call`. Asserted by emitting IR
/// for list_map_poly and grepping for the exact instruction. This is
/// the only direct evidence that the type-system marker actually
/// influences codegen — the e2e test above only checks observed
/// stdout, which `musttail` does not change.
#[test]
fn iter14e_print_list_recursion_emits_musttail() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
let src = workspace.join("examples").join("list_map_poly.ail");
let tmp = std::env::temp_dir().join(format!(
"ailang_iter14e_musttail_{}",
std::process::id()
));
std::fs::create_dir_all(&tmp).unwrap();
let out_ll = tmp.join("list_map_poly.ll");
let status = Command::new(ail_bin())
.args(["emit-ir", src.to_str().unwrap(), "-o"])
.arg(&out_ll)
.status()
.expect("ail emit-ir failed to run");
assert!(status.success(), "ail emit-ir failed");
let ir = std::fs::read_to_string(&out_ll).expect("read emitted IR");
// Find the musttail call to print_list inside the print_list body.
// The exact line shape:
// %vN = musttail call i8 @ail_list_map_poly_print_list(ptr %vM)
let has_musttail = ir.lines().any(|l| {
l.contains("musttail call")
&& l.contains("@ail_list_map_poly_print_list(")
});
assert!(
has_musttail,
"expected `musttail call ... @ail_list_map_poly_print_list(...)` \
in emitted IR; not found.\nIR:\n{ir}"
);
// Defence-in-depth: the musttail call must be immediately followed
// by a `ret i8 %v...`. We assert the next non-empty line is a ret.
let mut lines = ir.lines();
while let Some(line) = lines.next() {
if line.contains("musttail call")
&& line.contains("@ail_list_map_poly_print_list(")
{
let next = lines.next().unwrap_or("").trim();
assert!(
next.starts_with("ret i8 "),
"musttail call must be immediately followed by `ret i8 ...`; \
got: `{next}`"
);
return;
}
}
panic!("musttail call line not found (sanity check)");
}
/// insertion sort over an 11-element IntList.
/// Exercises `<=`, `if`, mutual-leaf recursion (`insert` and `sort`),
/// nested ctor construction, and the Iter 10 seq operator inside
/// print_list. Validates that the language handles deeper ADT
/// recursion + branching without surprises.
#[test]
fn insertion_sort_orders_list() {
let stdout = build_and_run("sort.ail");
let lines: Vec<&str> = stdout.lines().collect();
assert_eq!(
lines,
vec!["1", "1", "2", "3", "3", "4", "5", "5", "5", "6", "9"],
);
}
/// polymorphism end-to-end. `id : forall a. (a) -> a`
/// gets called with `42` (Int) and `true` (Bool); each instantiation
/// triggers a separate specialised LLVM fn. Output: 42, then "true".
#[test]
fn polymorphic_id_at_int_and_bool() {
let stdout = build_and_run("poly_id.ail");
let lines: Vec<&str> = stdout.lines().collect();
assert_eq!(lines, vec!["42", "true"]);
}
/// polymorphism with a function-typed parameter.
/// `apply : forall a b. ((a) -> b, a) -> b` invoked as
/// `apply(succ, 41) == 42`. Exercises the closure-pair ABI inside a
/// monomorphised body (a fn-typed param survives substitution).
#[test]
fn polymorphic_apply_with_fn_param() {
let stdout = build_and_run("poly_apply.ail");
assert_eq!(stdout.trim(), "42");
}
/// round-trip a primitive through a parameterised ADT and a
/// polymorphic-fn boundary. `type Box[a] = MkBox(a)` plus
/// `unbox : forall a. (Box<a>) -> a` plus `print_int(unbox(MkBox(42)))`.
/// Guards: ctor lower with substituted LLVM field types,
/// match-arm field bindings carrying the substituted AILang type
/// down into a polymorphic fn body.
#[test]
fn parameterised_box_round_trip() {
let stdout = build_and_run("box.ail");
assert_eq!(stdout.trim(), "42");
}
/// pattern-match on `Maybe<Int>`. `or_else(Some(7), 99) == 7`
/// then `or_else(None, 99) == 99`. Guards: match-arm field
/// substitution at a parameterised ctor (the `Some(x)` arm must bind
/// `x : Int`, not `x : a`).
#[test]
fn parameterised_maybe_match() {
let stdout = build_and_run("maybe_int.ail");
let lines: Vec<&str> = stdout.lines().collect();
assert_eq!(lines, vec!["7", "99"]);
}
/// cross-module reference to a parameterised ADT, including
/// its ctors and a polymorphic combinator instantiated at `(Int, Int)`.
/// `std_maybe_demo` imports `std_maybe`, qualifies the type-name slot
/// of every `term-ctor` (`std_maybe.Maybe`), and exercises
/// `from_maybe`, `is_some`, `is_none`, and `map_maybe` once each.
/// Property protected: the qualified-only convention (the
/// authoring surface's architectural pin extended to types and
/// ctors per the brief)
/// flows end-to-end through check, codegen, and runtime.
#[test]
fn cross_module_maybe_demo() {
let stdout = build_and_run("std_maybe_demo.ail");
let lines: Vec<&str> = stdout.lines().collect();
assert_eq!(lines, vec!["7", "99", "true", "true", "42"]);
}
/// drives the polymorphic `std_list` combinators end-to-end.
/// Exercises a recursive cross-module ADT (`std_list.List<a>`) consumed
/// from a separate module that also imports `std_maybe`. Guards the
/// `qualify_local_types` propagation in both `Term::Ctor` synth and
/// `Term::Match` field binding — without it, recursive ctor fields
/// (`Cons a (List a)`) stay unqualified at the cross-module use site
/// and fail to unify against the qualified scrutinee args.
#[test]
fn std_list_demo() {
let stdout = build_and_run("std_list_demo.ail");
let lines: Vec<&str> = stdout.lines().collect();
assert_eq!(
lines,
vec![
"5", "false", "true", "1", "4", "10", "5", "2", "2", "15", "15"
]
);
}
/// empirical stress test for `std_list`'s folds at N=1000.
///
/// Property protected: `fold_left`'s tail-call marker actually
/// survives monomorphisation and lowers to `musttail call` in the
/// monomorphised `fold_left__I_I` body, so a 1000-element fold runs
/// in constant stack and does not segfault. `fold_right` is
/// constructor-blocked (the recursive call sits as the second arg
/// to `f`, not in tail position) and runs at a 1000-deep stack —
/// well within the default 8MB Linux stack budget. `build` itself
/// is recursive-but-unmarked (Cons-blocked) and likewise relies on
/// the stack budget at this scale. Both folds over the same
/// commutative-associative `+` produce the same sum (500500 =
/// 1000 * 1001 / 2).
///
/// If `fold_left` crashed here the tail marker would have been
/// lost in monomorphisation; if `fold_right` or `build` crashed,
/// stack frames would be far larger than estimated. Neither
/// happens at N=1000.
#[test]
fn std_list_stress_1000_element_folds() {
let stdout = build_and_run("std_list_stress.ail");
let lines: Vec<&str> = stdout.lines().collect();
assert_eq!(
lines,
vec!["500500", "500500"],
"expected sum 1..1000 = 500500 from both fold_left and \
fold_right; mismatch indicates a fold bug or stack \
overflow corrupting output"
);
}
// Retired iter form-a.1 T7: `render_parse_round_trip_canonical` raw-read
// `std_either.ail.json` for the byte-roundtrip. The property it pinned
// (CLI render→parse byte-roundtrip) is now pinned across the entire
// `.ail` corpus by `cli_parse_then_render_then_parse_is_idempotent` in
// `crates/ail/tests/roundtrip_cli.rs` (added in T1), which is strictly
// stronger (whole-corpus vs. one-fixture).
/// nested constructor patterns. Property protected:
/// `ailang_core::desugar` flattens `Cons a (Cons b _)` to a chain of
/// single-level matches before the checker/codegen sees it. Without
/// the desugar pass the checker would emit
/// `nested-ctor-pattern-not-allowed`; without the pre-codegen call,
/// the inner binders would be silently dropped by the codegen.
#[test]
fn nested_ctor_pattern_first_two_sum() {
let stdout = build_and_run("nested_pat.ail");
let lines: Vec<&str> = stdout.lines().collect();
assert_eq!(lines, vec!["30"]);
}
/// `std_pair` end-to-end. Polymorphic product type with
/// two type vars and a single constructor; six combinators including
/// the 3-type-var `map_first` / `map_second` reshapers. Property
/// protected: `Pair<a, b> -> Pair<c, b>` and `Pair<a, b> -> Pair<a, c>`
/// monomorphise correctly when each is exercised at distinct
/// `(a, b, c)` triples — the codegen must emit the right field types
/// for the substituted MkPair output.
#[test]
fn std_pair_demo() {
let stdout = build_and_run("std_pair_demo.ail");
let lines: Vec<&str> = stdout.lines().collect();
assert_eq!(lines, vec!["7", "9", "9", "7", "8", "18"]);
}
/// `std_either` end-to-end. First stdlib ADT with two type
/// parameters (`Either<e, a>`); first combinator with three type vars
/// (`either : (e -> c) -> (a -> c) -> Either<e, a> -> c`).
///
/// Property protected: monomorphisation correctly distinguishes per-
/// call-site instantiations, including (a) two `from_right` instances
/// differing only in the unused `e` (Int vs $u) and (b) the
/// 3-type-var `either__I_I_I` instantiation. If wildcard substitution
/// regressed, one of those would either fail to emit or collide with
/// another instantiation.
#[test]
fn std_either_demo() {
let stdout = build_and_run("std_either_demo.ail");
let lines: Vec<&str> = stdout.lines().collect();
assert_eq!(
lines,
vec!["42", "99", "true", "true", "42", "6", "101"]
);
}
/// `std_either_list` end-to-end. First stdlib fn set that
/// imports three other stdlib modules (`std_list`, `std_either`,
/// `std_pair`) and returns a compound polymorphic ADT tree
/// (`partition_eithers : List<Either<e,a>> -> Pair<List<e>, List<a>>`).
///
/// Property protected: monomorphisation across `List × Either × Pair`
/// in a single call chain — `std_either_list.partition_eithers` is
/// instantiated at `(e=Int, a=Int)` and the result feeds
/// `std_pair.fst` / `std_pair.snd` (each at `(a=List<Int>, b=List<Int>)`)
/// into `std_list.length` (at `a=Int`). If cross-module ctor resolution
/// or substitution-through-nested-Con regressed, one of the
/// instantiations would either fail to emit or produce a wrong length.
/// The four expected outputs (2, 3, 2, 3) also pin the order
/// preservation of `lefts` and `rights` against the input
/// `[Left 1, Right 10, Left 2, Right 20, Right 30]`.
#[test]
fn std_either_list_demo() {
let stdout = build_and_run("std_either_list_demo.ail");
let lines: Vec<&str> = stdout.lines().collect();
assert_eq!(lines, vec!["2", "3", "2", "3"]);
}
/// `std_list.take` and `std_list.drop` end-to-end. Both are
/// index-driven recursive ADT-pattern fns — the first `std_list`
/// combinators that pair `if (<= n 0)` Int-arithmetic guards with a
/// recursive Cons/Nil match. Property protected: the `if` base-case
/// guard wraps a `match` whose recursive arm bodies use `(- n 1)` and
/// re-enter through `app` — a shape no other `std_list` fn has so
/// far. Six checks pin all three boundary regimes (n=0, 0<n<length,
/// n>length) on each combinator. Expected stdout: 0, 3, 5, 5, 3, 0.
#[test]
fn std_list_more_demo() {
let stdout = build_and_run("std_list_more_demo.ail");
let lines: Vec<&str> = stdout.lines().collect();
assert_eq!(lines, vec!["0", "3", "5", "5", "3", "0"]);
}
/// local recursive `let`. Property protected: a
/// `(let-rec ...)` term in a fn body whose recursive body has no
/// captures from the enclosing scope is lifted by the 16a desugar
/// pass to a synthetic top-level fn (`<name>$lr_N`), and the
/// resulting module type-checks and runs identically to a
/// hand-written top-level recursive fn. The fixture exercises
/// `fact` at n=1 (base case), n=3, n=5 — outputs 1, 6, 120.
/// If the desugar lift regressed (e.g. failed to substitute call
/// sites in the in-term, or generated a colliding lifted name),
/// the build would fail at typecheck or the binary would print
/// wrong values.
#[test]
fn local_rec_factorial_demo() {
let stdout = build_and_run("local_rec_demo.ail");
let lines: Vec<&str> = stdout.lines().collect();
assert_eq!(lines, vec!["1", "6", "120"]);
}
/// LetRec capture of a fn-param (path-1 safe subset).
/// Property protected: the desugar pass lifts a LetRec whose body
/// captures one or more enclosing-scope names (here: `n` from
/// `sum_below`'s params) into a synthetic top-level fn whose
/// signature has the capture types appended, and rewrites every
/// call site of the LetRec name in body and in_term to pass the
/// captures positionally as extra args. Without 16b.2, the
/// 16b.1-era panic ("would capture") would fire and the build
/// would fail. Three call sites: sum_below(1)=0, sum_below(5)=10,
/// sum_below(10)=45.
#[test]
fn local_rec_capture_demo() {
let stdout = build_and_run("local_rec_capture.ail");
let lines: Vec<&str> = stdout.lines().collect();
assert_eq!(lines, vec!["0", "10", "45"]);
}
/// LetRec capture of a `Term::Let`-bound name. Property
/// protected: the desugar pass leaves a LetRec whose only outside-
/// scope captures are Let-bound (type unknown until typecheck) in
/// place; the post-typecheck `lift_letrecs` pass in `ailang-check`
/// resolves capture types from the typechecker's env, lifts to a
/// synthetic top-level fn (`loop$lr_0(i, n, threshold)`), and
/// rewrites every call site. Without 16b.3, the 16b.2-era panic
/// ("16b.3 — let-binding captures") would fire. The threshold here
/// is `(app + 5 5)` so the lift exercises the type-synthesis path
/// (not just a literal). count_below(0)=0, count_below(5)=5,
/// count_below(15)=9 (i in 1..9 are below threshold 10).
#[test]
fn local_rec_let_capture_demo() {
let stdout = build_and_run("local_rec_let_capture.ail");
let lines: Vec<&str> = stdout.lines().collect();
assert_eq!(lines, vec!["0", "5", "9"]);
}
/// LetRec capture of a Match-arm pattern binding.
/// Property protected: the desugar pass leaves a LetRec whose
/// captures are `Pattern::Ctor`-Var-bound (here `threshold` and
/// `n`, both fields of `MkPair` in `Pair Int Int`) in place; the
/// post-typecheck `lift_letrecs` pass walks the enclosing
/// `Term::Match` arm, calls `type_check_pattern_for_lift` to
/// substitute the matched ADT's type args (`[Int, Int]`) into
/// `MkPair`'s declared field types, and lifts to a synthetic
/// top-level fn `loop$lr_0(i: Int, threshold: Int, n: Int) ->
/// Int`, rewriting every call site. Without 16b.4, the 16b.3-era
/// panic ("16b.4 — match-arm captures") would fire.
/// count_below(MkPair 10 0)=0, count_below(MkPair 10 5)=5,
/// count_below(MkPair 10 15)=9.
#[test]
fn local_rec_match_capture_demo() {
let stdout = build_and_run("local_rec_match_capture.ail");
let lines: Vec<&str> = stdout.lines().collect();
assert_eq!(lines, vec!["0", "5", "9"]);
}
/// LetRec name as a value in the in-clause (no capture).
/// Property protected: the desugar pass detects a non-callee use of
/// the LetRec name in `in_term` and wraps the rewritten in-term in
/// `(let factorial (lam ...) ...)` whose lam eta-expands the lifted
/// fn. The LetRec name then resolves to a closure-pair value usable
/// anywhere a `Fn(Int) -> Int` is expected (here: passed to a HOF
/// `apply5`). Without 16b.5, the desugar would panic with the
/// "name-as-value not supported" message and the build would fail.
/// Expected stdout: 120 (= apply5 factorial = factorial 5).
#[test]
fn local_rec_as_value_demo() {
let stdout = build_and_run("local_rec_as_value.ail");
let lines: Vec<&str> = stdout.lines().collect();
assert_eq!(lines, vec!["120"]);
}
/// LetRec name as a value in the in-clause WITH capture.
/// Property protected: the eta-Lam wrap composes correctly with the
/// 16b.2 capture-augmentation. The lifted fn has signature
/// `factorial_plus$lr_N(n: Int, base: Int) -> Int`; the eta-Lam
/// wrap binds `factorial_plus` to a `(lam (n) (app
/// factorial_plus$lr_N n base))` whose free var `base` becomes a
/// standard 8b closure-env capture. This is the dynamic-env path:
/// the lifted fn has extra params, the eta-Lam captures them.
/// Expected stdout: 1320 (= 5*4*3*2*(1+10)), 12120 (= 5*4*3*2*(1+100)).
#[test]
fn local_rec_as_value_capture_demo() {
let stdout = build_and_run("local_rec_as_value_capture.ail");
let lines: Vec<&str> = stdout.lines().collect();
assert_eq!(lines, vec!["1320", "12120"]);
}
/// LetRec inside a polymorphic enclosing fn.
/// Property protected: when the enclosing fn is `Forall(a). Fn(...)`,
/// the desugar/lift pipeline now (a) enters fn-params as `KnownType`
/// (not `LetBound`) so a LetRec inside CAN capture them, and (b)
/// builds the synthetic lifted fn's signature as
/// `Forall(a). Fn(t1..tk, T1..Tm) -> tr`, mirroring the enclosing
/// fn's type vars. Capture types `T1..Tm` may mention `a`; the
/// outer Forall binds them. Codegen's mono pipeline (Iter 12b/14a)
/// then specialises `loop$lr_0` at every call with the same type
/// args as the enclosing `apply_n_times`.
///
/// `main` drives `apply_n_times` at TWO distinct instantiations
/// (`Int` with `succ`, `Bool` with `flip`) so the mono path is
/// exercised twice, producing both `loop$lr_0__I` and
/// `loop$lr_0__B` in the IR. Without 16b.6 the desugar pass would
/// have rejected the LetRec capture as a `LetBound`-style failure
/// (the 16b.2-era `Type::Forall` fallback marked all fn-params as
/// `LetBound`).
///
/// Expected stdout: 5 (succ applied 5 times to 0), false (flip
/// applied 4 times to false — even count of flips ⇒ false).
#[test]
fn poly_rec_capture_demo() {
let stdout = build_and_run("poly_rec_capture.ail");
let lines: Vec<&str> = stdout.lines().collect();
assert_eq!(lines, vec!["5", "false"]);
}
/// nested LetRec where the inner one captures the OUTER
/// LetRec's PARAMS (not its name).
///
/// Property protected: when an inner LetRec lifts inside an outer
/// LetRec's body, the outer's params are `ScopeEntry::KnownType` in
/// the inner's outer-scope (set up by the desugar pass at the same
/// site that marks the outer's NAME as `EnclosingLetRec`). The inner
/// lift therefore takes the 16b.2 fast path with the outer's params
/// appended to the inner's signature; once the outer is then lifted,
/// the call sites of `inner$lr_M(args, outer_param)` continue to
/// resolve `outer_param` as outer's still-in-scope param (later
/// renamed by the outer's own lift to its lifted-fn param of the
/// same name). Inner capturing the OUTER's NAME remains rejected —
/// see the desugar/lift `EnclosingLetRec` panic with the
/// closure-of-self message.
///
/// `nested_sum(n)` = sum of 1..=n by counting i ones for each i in
/// 1..=n. Expected stdout: 1, 6, 15 for n in {1, 3, 5}.
#[test]
fn nested_let_rec_demo() {
let stdout = build_and_run("nested_let_rec.ail");
let lines: Vec<&str> = stdout.lines().collect();
assert_eq!(lines, vec!["1", "6", "15"]);
}
/// Guards `ail diff`: a modified body changes the hash of `sum`, while
/// `main` stays unchanged. Expects exit code 1, `changed` contains exactly
/// `sum`, `unchanged` contains `main`, `added`/`removed` empty.
#[test]
fn diff_detects_changed_def() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
// Form-A migration (iter form-a.1): derive the canonical JSON-AST
// from `sum.ail` via `ail parse` rather than reading a pre-existing
// `sum.ail.json` (which no longer exists post-iter). The variant is
// produced by mutating the parsed AST and writing it back as a JSON
// tempfile; `ail diff` runs against the two JSON tempfiles.
let tmp_a = std::env::temp_dir().join(format!(
"ailang_diff_src_{}.ail.json",
std::process::id()
));
let parse_status = Command::new(ail_bin())
.args([
"parse",
workspace.join("examples").join("sum.ail").to_str().unwrap(),
"-o",
tmp_a.to_str().unwrap(),
])
.status()
.expect("ail parse failed to run");
assert!(parse_status.success(), "ail parse failed on sum.ail");
let src_a = tmp_a.clone();
// Variant: load the derived JSON, mutate the `then` branch (1 instead of 0)
// in the `sum` definition. `main` stays bit-identical.
let raw = std::fs::read(&src_a).expect("read derived sum.ail.json");
let mut module: serde_json::Value =
serde_json::from_slice(&raw).expect("parse derived sum.ail.json");
{
let defs = module
.get_mut("defs")
.and_then(|d| d.as_array_mut())
.expect("defs array");
for def in defs.iter_mut() {
if def.get("name").and_then(|n| n.as_str()) == Some("sum") {
// Replace the then branch literal 0 → literal 1.
let new_then = serde_json::json!({
"t": "lit",
"lit": { "kind": "int", "value": 1 }
});
def["body"]["then"] = new_then;
}
}
}
let tmp = std::env::temp_dir().join(format!(
"ailang_diff_changed_{}",
std::process::id()
));
std::fs::create_dir_all(&tmp).unwrap();
let path_b = tmp.join("sum_v2.ail.json");
std::fs::write(&path_b, serde_json::to_vec_pretty(&module).unwrap())
.expect("write sum_v2.ail.json");
let output = Command::new(ail_bin())
.args([
"diff",
src_a.to_str().unwrap(),
path_b.to_str().unwrap(),
"--json",
])
.output()
.expect("ail diff failed to run");
let code = output.status.code().expect("process terminated by signal");
assert_eq!(
code,
1,
"expected exit code 1 for differing modules; stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).expect("stdout utf8");
let v: serde_json::Value =
serde_json::from_str(stdout.trim()).expect("stdout must be valid JSON");
let added = v["added"].as_array().expect("added array");
let removed = v["removed"].as_array().expect("removed array");
let changed = v["changed"].as_array().expect("changed array");
let unchanged = v["unchanged"].as_array().expect("unchanged array");
assert!(added.is_empty(), "expected added empty: {added:?}");
assert!(removed.is_empty(), "expected removed empty: {removed:?}");
assert_eq!(changed.len(), 1, "expected one changed entry: {changed:?}");
assert_eq!(
changed[0].get("name").and_then(|n| n.as_str()),
Some("sum")
);
assert_ne!(
changed[0].get("hash_a").and_then(|n| n.as_str()),
changed[0].get("hash_b").and_then(|n| n.as_str()),
"hash_a and hash_b must differ for a changed def"
);
assert!(
unchanged.iter().any(|e| e.get("name").and_then(|n| n.as_str()) == Some("main")),
"expected `main` in unchanged: {unchanged:?}"
);
}
/// Diff of a module with itself: exit 0, all lists except `unchanged` empty.
#[test]
fn diff_no_changes_exit_zero() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
let src = workspace.join("examples").join("sum.ail");
let output = Command::new(ail_bin())
.args([
"diff",
src.to_str().unwrap(),
src.to_str().unwrap(),
"--json",
])
.output()
.expect("ail diff failed to run");
let code = output.status.code().expect("process terminated by signal");
assert_eq!(
code,
0,
"expected exit code 0 for identical modules; stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).expect("stdout utf8");
let v: serde_json::Value =
serde_json::from_str(stdout.trim()).expect("stdout must be valid JSON");
assert!(v["added"].as_array().unwrap().is_empty());
assert!(v["removed"].as_array().unwrap().is_empty());
assert!(v["changed"].as_array().unwrap().is_empty());
assert!(
!v["unchanged"].as_array().unwrap().is_empty(),
"self-diff should report unchanged defs"
);
}
/// Guards the workspace loader (Iter 5a): the entry module `ws_main`
/// imports `ws_lib`; both must be found by the loader, loaded, and listed
/// in the JSON output. Cross-module typecheck/codegen is explicitly not
/// part of this test — it only verifies the loader pipeline.
#[test]
fn workspace_lists_imported_modules() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
let entry = workspace.join("examples").join("ws_main.ail");
let output = Command::new(ail_bin())
.args(["workspace", entry.to_str().unwrap(), "--json"])
.output()
.expect("ail workspace failed to run");
assert!(
output.status.success(),
"ail workspace exited non-zero; stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).expect("stdout utf8");
let v: serde_json::Value =
serde_json::from_str(stdout.trim()).expect("stdout must be valid JSON");
assert_eq!(
v.get("entry").and_then(|n| n.as_str()),
Some("ws_main"),
"entry must be ws_main: {stdout}"
);
let modules = v["modules"].as_array().expect("modules must be array");
// the loader auto-injects the `prelude` module, so
// the count is the user's two modules plus prelude.
assert_eq!(modules.len(), 3, "expected 3 modules: {stdout}");
let names: Vec<&str> = modules
.iter()
.filter_map(|m| m.get("name").and_then(|n| n.as_str()))
.collect();
assert!(names.contains(&"ws_main"), "ws_main missing: {names:?}");
assert!(names.contains(&"ws_lib"), "ws_lib missing: {names:?}");
assert!(names.contains(&"prelude"), "prelude missing: {names:?}");
}
/// Guards Iter 5b: `ail check examples/ws_main.ail.json --json` must
/// resolve the cross-module call `ws_lib.add`. Expected: exit 0,
/// stdout exactly `[]` (empty diagnostic array).
#[test]
fn check_workspace_resolves_import() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
let entry = workspace.join("examples").join("ws_main.ail");
let output = Command::new(ail_bin())
.args(["check", entry.to_str().unwrap(), "--json"])
.output()
.expect("ail check --json failed to run");
let code = output.status.code().expect("process terminated by signal");
assert_eq!(
code,
0,
"expected exit 0; stderr: {}; stdout: {}",
String::from_utf8_lossy(&output.stderr),
String::from_utf8_lossy(&output.stdout),
);
let stdout = String::from_utf8(output.stdout).expect("stdout utf8");
assert_eq!(stdout.trim(), "[]", "expected empty diagnostic array");
}
/// Guards Iter 5c (cross-module codegen): `ail build` over
/// `examples/ws_main.ail.json` must lower the workspace incl. `ws_lib`;
/// `@ail_ws_main_main` must call `@ail_ws_lib_add(2,3)` and print `5`.
#[test]
fn workspace_build_runs_imported_fn() {
let stdout = build_and_run("ws_main.ail");
assert_eq!(stdout.trim(), "5", "ws_lib.add(2,3) should print 5");
}
/// Guards Iter 23.1: `examples/ordering_match.ail.json` pattern-matches
/// on the prelude `Ordering` ctor `LT` without an explicit prelude import.
/// End-to-end: typecheck → codegen → link → run → stdout exactly `"1\n"`.
/// Property: the implicit prelude import wired by `build_check_env` /
/// `check_in_workspace` (Iter 23.1 Task 3) propagates through codegen so
/// the bare ctor name `LT` resolves cross-module without an explicit
/// `imports: ["prelude"]` entry in the source module.
#[test]
fn ordering_match_via_prelude_prints_1() {
let stdout = build_and_run("ordering_match.ail");
assert_eq!(stdout, "1\n");
}
/// the end-to-end
/// demonstration that the iter-23.1 cross-module Type::Con
/// mismatch bug is closed. `examples/compare_primitives_smoke.ail.json`
/// imports prelude, calls `compare` on three pairs each of
/// Int / Bool / Str (arranged LT / EQ / GT per type), and
/// pattern-matches the result. Expected stdout encodes the
/// 9-case decision lattice as `1\n2\n3` × 3.
///
/// Pre-canonical-type-names, the bare `Ordering` returned by
/// `compare` mismatched the qualified `prelude.Ordering`
/// scrutinee at the user-side pattern lookup. Post-milestone,
/// the scrutinee is qualified throughout, the pattern lookup
/// is type-driven, and the run completes cleanly.
#[test]
fn compare_primitives_smoke_prints_1_2_3_thrice() {
let stdout = build_and_run("compare_primitives_smoke.ail");
assert_eq!(stdout, "1\n2\n3\n1\n2\n3\n1\n2\n3\n");
}
/// end-to-end coverage for the auto-loaded `Eq` class
/// and its three primitive instances. The fixture calls `eq` on
/// Int / Bool / Str with both an equal and an unequal pair each;
/// the monomorphiser synthesises `eq__Int` / `eq__Bool` /
/// `eq__Str` in the prelude module; the binary prints six lines,
/// one per call. Equal-call lines are `1`, unequal-call lines are
/// `0`.
#[test]
fn eq_primitives_smoke_compiles_and_runs() {
let stdout = build_and_run("eq_primitives_smoke.ail");
assert_eq!(stdout, "1\n0\n1\n0\n1\n0\n");
}
/// Guards Iter 5d: `ail manifest --workspace --json` lists defs from all
/// modules of the workspace — visible via a `module` field per entry.
#[test]
fn manifest_workspace_lists_all_defs() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
let entry = workspace.join("examples").join("ws_main.ail");
let output = Command::new(ail_bin())
.args([
"manifest",
entry.to_str().unwrap(),
"--workspace",
"--json",
])
.output()
.expect("ail manifest --workspace failed to run");
assert!(
output.status.success(),
"ail manifest --workspace exited non-zero; stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).expect("stdout utf8");
let v: serde_json::Value =
serde_json::from_str(stdout.trim()).expect("stdout must be valid JSON");
let symbols = v["symbols"].as_array().expect("symbols must be array");
let modules: Vec<&str> = symbols
.iter()
.filter_map(|s| s.get("module").and_then(|m| m.as_str()))
.collect();
assert!(
modules.contains(&"ws_main"),
"expected at least one symbol with module=ws_main; got {modules:?}"
);
assert!(
modules.contains(&"ws_lib"),
"expected at least one symbol with module=ws_lib; got {modules:?}"
);
}
/// Guards Iter 5d: `ail describe --workspace ws_lib.add` resolves the
/// qualified dot notation to the actually imported module.
#[test]
fn describe_workspace_resolves_qualified_name() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
let entry = workspace.join("examples").join("ws_main.ail");
let output = Command::new(ail_bin())
.args([
"describe",
entry.to_str().unwrap(),
"ws_lib.add",
"--workspace",
"--json",
])
.output()
.expect("ail describe --workspace failed to run");
assert!(
output.status.success(),
"ail describe --workspace exited non-zero; stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).expect("stdout utf8");
let v: serde_json::Value =
serde_json::from_str(stdout.trim()).expect("stdout must be valid JSON");
assert_eq!(
v.get("module").and_then(|m| m.as_str()),
Some("ws_lib"),
"module field must be ws_lib; got {stdout}"
);
assert_eq!(
v.get("name").and_then(|n| n.as_str()),
Some("add"),
"def name must be add; got {stdout}"
);
}
/// Guards Iter 6: `ail deps` no longer surfaces built-ins, params, or
/// let-bindings as edges. The single-module `sum` def in `sum.ail.json`
/// uses `+`, `-`, `eq` (class-method dispatch via prelude.Eq), the param
/// `n`, and the recursive call `sum`. Arithmetic builtins (`+`/`-`) and
/// the local param drop out; what remains are the recursive call and
/// the class-method `eq` cross-module reference.
#[test]
fn deps_filters_builtins_params_locals() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
let src = workspace.join("examples").join("sum.ail");
let output = Command::new(ail_bin())
.args(["deps", src.to_str().unwrap(), "--of", "sum", "--json"])
.output()
.expect("ail deps failed to run");
assert!(
output.status.success(),
"ail deps exited non-zero; stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).expect("stdout utf8");
let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON");
let arr = v.as_array().expect("array of {name, refs}");
let entry = arr
.iter()
.find(|e| e["name"].as_str() == Some("sum"))
.expect("sum entry present");
let refs: Vec<&str> = entry["refs"]
.as_array()
.expect("refs array")
.iter()
.filter_map(|x| x.as_str())
.collect();
assert_eq!(
refs,
vec!["eq", "sum"],
"expected the class-method `eq` reference and the recursive call `sum`; got {refs:?}"
);
}
/// Guards Iter 6 in workspace mode: edges into built-ins (`ws_lib.+`)
/// and into the params (`ws_lib.a`, `ws_lib.b`) of `ws_lib.add` must be
/// gone after the deps refactor.
#[test]
fn deps_workspace_filters_builtins_and_params() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
let entry = workspace.join("examples").join("ws_main.ail");
let output = Command::new(ail_bin())
.args(["deps", entry.to_str().unwrap(), "--workspace", "--json"])
.output()
.expect("ail deps --workspace failed to run");
assert!(output.status.success());
let stdout = String::from_utf8(output.stdout).expect("stdout utf8");
let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON");
let edges = v["edges"].as_array().expect("edges array");
for e in edges {
let to_def = e.get("to_def").and_then(|x| x.as_str()).unwrap_or("");
assert_ne!(to_def, "+", "built-in `+` must not appear: {e}");
assert_ne!(to_def, "a", "param `a` must not appear: {e}");
assert_ne!(to_def, "b", "param `b` must not appear: {e}");
}
}
/// Guards Iter 5d: `ail deps --workspace` contains a cross-module edge
/// `ws_main.main -> ws_lib.add`, because `ws_main` calls `ws_lib.add(2,3)`.
#[test]
fn deps_workspace_includes_cross_module() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
let entry = workspace.join("examples").join("ws_main.ail");
let output = Command::new(ail_bin())
.args(["deps", entry.to_str().unwrap(), "--workspace", "--json"])
.output()
.expect("ail deps --workspace failed to run");
assert!(
output.status.success(),
"ail deps --workspace exited non-zero; stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).expect("stdout utf8");
let v: serde_json::Value =
serde_json::from_str(stdout.trim()).expect("stdout must be valid JSON");
let edges = v["edges"].as_array().expect("edges must be array");
let has_cross_edge = edges.iter().any(|e| {
e.get("from_module").and_then(|s| s.as_str()) == Some("ws_main")
&& e.get("from_def").and_then(|s| s.as_str()) == Some("main")
&& e.get("to_module").and_then(|s| s.as_str()) == Some("ws_lib")
&& e.get("to_def").and_then(|s| s.as_str()) == Some("add")
});
assert!(
has_cross_edge,
"expected cross-module edge ws_main.main -> ws_lib.add; got {stdout}"
);
}
/// Guards Iter 5d: `ail diff --workspace` detects an additional module
/// in the B workspace as an `added_modules` entry and exits with code 1.
#[test]
fn diff_workspace_added_module() {
use std::fs;
fn write_module(dir: &Path, name: &str, body: serde_json::Value) {
let p = dir.join(format!("{name}.ail.json"));
fs::write(&p, serde_json::to_vec_pretty(&body).unwrap()).unwrap();
}
let dir_a = std::env::temp_dir().join(format!(
"ailang_diff_ws_a_{}",
std::process::id()
));
let dir_b = std::env::temp_dir().join(format!(
"ailang_diff_ws_b_{}",
std::process::id()
));
let _ = fs::remove_dir_all(&dir_a);
let _ = fs::remove_dir_all(&dir_b);
fs::create_dir_all(&dir_a).unwrap();
fs::create_dir_all(&dir_b).unwrap();
// Both workspaces have an empty module `root`. B additionally
// imports `extra`. Loader convention: `<root_dir>/<name>.ail.json`,
// module name must match the file name.
let empty_root = serde_json::json!({
"schema": "ailang/v0",
"name": "root",
"imports": [],
"defs": [],
});
let root_with_import = serde_json::json!({
"schema": "ailang/v0",
"name": "root",
"imports": [{ "module": "extra" }],
"defs": [],
});
let extra = serde_json::json!({
"schema": "ailang/v0",
"name": "extra",
"imports": [],
"defs": [],
});
write_module(&dir_a, "root", empty_root);
write_module(&dir_b, "root", root_with_import);
write_module(&dir_b, "extra", extra);
let entry_a = dir_a.join("root.ail.json");
let entry_b = dir_b.join("root.ail.json");
let output = Command::new(ail_bin())
.args([
"diff",
entry_a.to_str().unwrap(),
entry_b.to_str().unwrap(),
"--workspace",
"--json",
])
.output()
.expect("ail diff --workspace failed to run");
let code = output.status.code().expect("process terminated by signal");
assert_eq!(
code,
1,
"expected exit 1 when workspaces differ; stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).expect("stdout utf8");
let v: serde_json::Value =
serde_json::from_str(stdout.trim()).expect("stdout must be valid JSON");
let added = v["added_modules"]
.as_array()
.expect("added_modules must be array");
assert!(
added.iter().any(|e| e.get("name").and_then(|n| n.as_str()) == Some("extra")),
"expected `extra` in added_modules; got {stdout}"
);
let removed = v["removed_modules"]
.as_array()
.expect("removed_modules must be array");
assert!(
removed.is_empty(),
"expected removed_modules empty; got {stdout}"
);
}
/// Guards the `--json` diagnostic format for tooling consumers.
/// `broken_unbound.ail.json` references a non-existent variable;
/// exit code 1 is expected, plus at least one diagnostic with
/// `severity == "error"` and `code == "unbound-var"`.
#[test]
fn check_json_unbound_var() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
let src = workspace.join("examples").join("broken_unbound.ail.json");
let output = Command::new(ail_bin())
.args(["check", src.to_str().unwrap(), "--json"])
.output()
.expect("ail check --json failed to run");
let code = output.status.code().expect("process terminated by signal");
assert_eq!(code, 1, "expected exit code 1, stderr: {}", String::from_utf8_lossy(&output.stderr));
let stdout = String::from_utf8(output.stdout).expect("stdout utf8");
let diags: serde_json::Value =
serde_json::from_str(stdout.trim()).expect("stdout must be valid JSON");
let arr = diags.as_array().expect("diagnostics must be a JSON array");
assert!(
arr.iter().any(|d| {
d.get("severity").and_then(|v| v.as_str()) == Some("error")
&& d.get("code").and_then(|v| v.as_str()) == Some("unbound-var")
}),
"expected at least one error diagnostic with code unbound-var; got: {stdout}"
);
}
/// literal patterns at top level and inside Ctor
/// sub-patterns. Property protected: the 16a desugar pass rewrites
/// every `Pattern::Lit` to a `Term::If` against the scrutinee/field
/// before either typecheck or codegen sees it. Without 16c, the
/// codegen would emit a Match arm with a Lit pattern and produce
/// either a wrong result or an `unreachable!` panic depending on
/// the path. The fixture exercises both sites: `classify` (top-
/// level lit arms) and `categorize_first` (nested lit-in-Ctor).
///
/// the trailing `(case _ 0)` arm of
/// `categorize_first` was removed when the chain machinery's
/// terminator switched from a `Unit` literal to the polymorphic
/// `__unreachable__` builtin. Output is unchanged — this test
/// guards both 16c (lit-pattern desugar) and 16d (no-workaround
/// chain default).
#[test]
fn lit_pat_demo() {
let stdout = build_and_run("lit_pat.ail");
let lines: Vec<&str> = stdout.lines().collect();
assert_eq!(lines, vec!["100", "200", "999", "-1", "0", "7"]);
}
/// `__unreachable__` as an explicit user-callable
/// primitive. Property protected: a polymorphic `forall a. a` value
/// reference (a) typechecks against any expected type at the use
/// site (here `Int`), and (b) codegen lowers it to LLVM
/// `unreachable`, with the surrounding `if` correctly forwarding
/// the live branch's value past the terminated branch. The fixture
/// only ever hits the live branch, so the binary must succeed and
/// print the expected outputs.
#[test]
fn unreachable_demo() {
let stdout = build_and_run("unreachable_demo.ail");
let lines: Vec<&str> = stdout.lines().collect();
assert_eq!(lines, vec!["4", "5"]);
}
/// `(borrow T)` and `(own T)` mode annotations on
/// fn-type parameters. Properties protected:
/// (1) the parser accepts `(borrow ...)` and `(own ...)` only as
/// wrappers in fn-type param/ret slots and round-trips them
/// into [`ailang_core::ParamMode`] on `Type::Fn`;
/// (2) the canonical-JSON serialisation emits `param_modes` only
/// when at least one mode is non-Implicit (verified by reading
/// the file and confirming the substring is present), so
/// pre-18a fixtures continue to hash bit-identically;
/// (3) the typechecker treats modes as transparent — it accepts a
/// fn whose parameter is `(borrow (con List))` even when the
/// same value is later passed to a fn whose parameter is
/// `(own (con List))`, because Iter 18a does not enforce
/// mode compatibility (deferred to 18c);
/// (4) codegen ignores modes entirely: list_length and sum_list
/// compile to the same LLVM IR they would have without the
/// wrappers, and the binary prints `3` then `6`.
#[test]
fn borrow_own_demo_modes_are_metadata_only() {
// Sanity-check (2): the canonical JSON contains `param_modes`
// tokens for both fns. If the schema regressed and the field
// were dropped, this would fail before the binary even runs.
//
// Form-A migration (iter form-a.1): derive the JSON via `ail parse`
// on `borrow_own_demo.ail` rather than reading a pre-existing
// `.ail.json` (deleted post-iter).
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
let tmp_json = std::env::temp_dir().join(format!(
"ailang_borrow_own_demo_{}.ail.json",
std::process::id()
));
let parse_status = Command::new(ail_bin())
.args([
"parse",
workspace.join("examples").join("borrow_own_demo.ail").to_str().unwrap(),
"-o",
tmp_json.to_str().unwrap(),
])
.status()
.expect("ail parse failed to run");
assert!(parse_status.success(), "ail parse failed on borrow_own_demo.ail");
let json = std::fs::read_to_string(&tmp_json).expect("read derived borrow_own_demo.ail.json");
assert!(
json.contains("\"param_modes\":[\"borrow\"]"),
"expected `param_modes:[\"borrow\"]` in canonical JSON; the schema for `(borrow T)` regressed"
);
assert!(
json.contains("\"param_modes\":[\"own\"]"),
"expected `param_modes:[\"own\"]` in canonical JSON; the schema for `(own T)` regressed"
);
let stdout = build_and_run("borrow_own_demo.ail");
let lines: Vec<&str> = stdout.lines().collect();
assert_eq!(lines, vec!["3", "6"]);
}
/// Class-dispatched `eq` over Int / Bool / Str / Unit. Properties protected:
/// (1) `eq` resolves via `prelude.Eq.eq` for every supported primitive
/// type (the fixture uses every case directly via `(app eq ...)`);
/// (2) the codegen intercept `try_emit_primitive_instance_body` emits
/// the right single-instruction body per type — `icmp eq i64`,
/// `icmp eq i1`, `@ail_str_eq`, constant `i1 1` — and the binary's
/// stdout matches the boolean truth-table for those equalities;
/// (3) `build_eq`'s desugar of `(pat-lit "hi")` over a `Str`
/// scrutinee routes through the class-method `eq` (not the
/// deleted operator `==`), exercising the same Eq Str instance
/// body via the lit-pattern path.
#[test]
fn eq_demo() {
let stdout = build_and_run("eq_demo.ail");
let lines: Vec<&str> = stdout.lines().collect();
assert_eq!(
lines,
vec![
"true", "false", // == on Int
"false", "true", // == on Bool
"true", "false", // == on Str
"true", // == on Unit
"1", "2", "0", // classify_str: Str lit-pattern desugar
]
);
}
/// `Term::Clone` is a pure schema addition. In 18c.1 the
/// wrapper is identity for both typechecker (same type as inner) and
/// codegen (same SSA reg, no extra IR) — programs that contain
/// `(clone X)` produce the same stdout they would have produced
/// without the wrapper. Iter 18c.3 will replace the codegen
/// passthrough with `call void @ailang_rc_inc` under `--alloc=rc`.
#[test]
fn clone_demo_is_identity_in_18c1() {
let stdout = build_and_run("clone_demo.ail");
assert_eq!(stdout.trim(), "42");
}
/// under `--alloc=rc`, `Term::ReuseAs { source, body =
/// Term::Ctor }` lowers to a runtime refcount-1 dispatch — when the
/// source's box is unique we overwrite it in place (skipping the
/// alloc + cascade-dec round-trip); otherwise we fall back to a
/// fresh allocation and dec the source.
///
/// Properties guarded:
/// (1) The fixture's canonical JSON contains `"t":"reuse-as"` — the
/// schema for the new variant did not regress.
/// (2) `--alloc=rc` produces `9` (1+1 + 2+1 + 3+1) — the in-place
/// rewrite produces correct output.
/// (3) `--alloc=rc` exits cleanly (status 0; no segfault, no
/// refcount underflow). `build_and_run_with_alloc` panics on
/// non-zero exit, so this is implicit but worth naming.
/// (5) The emitted IR for `--alloc=rc` contains both:
/// - `icmp eq i64 %... 1` (the refcount-1 branch test)
/// - a path that does NOT call `@ailang_rc_alloc` for the inner
/// Cons ctor on the reuse arm — verified by the
/// `reuse.<id>:` block label and the absence of
/// `@ailang_rc_alloc` calls inside it.
#[test]
fn reuse_as_demo_under_rc_uses_inplace_rewrite() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
let ail_path = workspace.join("examples").join("reuse_as_demo.ail");
// Form-A migration (iter form-a.1): derive the canonical JSON-AST
// via `ail parse` on `reuse_as_demo.ail` rather than reading a
// pre-existing `.ail.json` (deleted post-iter).
let tmp_json = std::env::temp_dir().join(format!(
"ailang_reuse_as_demo_{}.ail.json",
std::process::id()
));
let parse_status = Command::new(ail_bin())
.args([
"parse",
ail_path.to_str().unwrap(),
"-o",
tmp_json.to_str().unwrap(),
])
.status()
.expect("ail parse failed to run");
assert!(parse_status.success(), "ail parse failed on reuse_as_demo.ail");
let json = std::fs::read_to_string(&tmp_json).expect("read derived reuse_as_demo.ail.json");
assert!(
json.contains("\"t\":\"reuse-as\""),
"expected `\"t\":\"reuse-as\"` in canonical JSON; the schema for `(reuse-as ...)` regressed"
);
// (2) rc build produces the expected stdout (clean exit via
// build_and_run_with_alloc panicking on non-zero status).
let stdout_rc = build_and_run_with_alloc("reuse_as_demo.ail", "rc");
assert_eq!(stdout_rc.trim(), "9");
// (5) Inspect the rc IR text. We re-lower via the codegen API
// directly so the assertion runs without depending on the
// filesystem layout of `ail build`'s tmpdir. Use the
// extension-dispatching `ailang_surface::load_workspace` so
// the `.ail` entry is accepted.
let ws = ailang_surface::load_workspace(&ail_path).expect("load workspace");
let mut lifted_modules = std::collections::BTreeMap::new();
for (mname, m) in &ws.modules {
let desugared = ailang_core::desugar::desugar_module(m);
let lifted = ailang_check::lift_letrecs(&desugared)
.unwrap_or_else(|e| panic!("lift_letrecs in module `{mname}`: {e:?}"));
lifted_modules.insert(mname.clone(), lifted);
}
let lifted_ws = ailang_core::Workspace {
entry: ws.entry.clone(),
modules: lifted_modules,
root_dir: ws.root_dir.clone(),
registry: ws.registry.clone(),
};
// post-print-migration, fixtures use `(app print x)`
// which monomorphises to `print__<T>`. Adding the mono pass here
// mirrors `ail build`'s pipeline (desugar -> lift -> mono ->
// lower); without it, lowering errors with `UnknownVar("print")`.
let mono_ws = ailang_check::monomorphise_workspace(&lifted_ws)
.expect("monomorphise_workspace");
let ir = ailang_codegen::lower_workspace_with_alloc(
&mono_ws,
ailang_codegen::AllocStrategy::Rc,
)
.expect("lower workspace under rc");
// The refcount-1 branch test: lower_reuse_as_rc emits exactly
// one such instruction per reuse-as site. Search lenient on the
// SSA register name (we don't care which `%v...` was assigned).
assert!(
ir.contains("icmp eq i64") && ir.contains(", 1\n"),
"rc IR missing `icmp eq i64 %... 1` (the refcount-1 branch test). IR was:\n{ir}"
);
assert!(
ir.contains("br i1") && ir.contains("label %reuse"),
"rc IR missing `br i1 ... label %reuse...` (the refcount-1 branch). IR was:\n{ir}"
);
// The reuse arm must NOT call `@ailang_rc_alloc` — that's the
// whole point of the in-place rewrite. We check the substring
// that begins with the reuse label and ends at the next label
// (the label `rejoin.` per `lower_reuse_as_rc`'s scheme).
let reuse_idx = ir.find("reuse.").expect("reuse label present");
// Walk forward to the next block label after the reuse: arm.
// `lower_reuse_as_rc` emits `reuse.<id>:` then code, then
// `br label %rejoin.<id>`. The fresh arm immediately follows
// as `fresh.<id>:` — that's the boundary.
let after_reuse = &ir[reuse_idx..];
let reuse_arm_end = after_reuse
.find("\nfresh.")
.expect("fresh label follows the reuse arm in lower_reuse_as_rc layout");
let reuse_arm = &after_reuse[..reuse_arm_end];
assert!(
!reuse_arm.contains("@ailang_rc_alloc"),
"reuse arm must not call @ailang_rc_alloc (the slot is reused in place). Reuse arm IR was:\n{reuse_arm}"
);
// in this canonical fixture the pattern `(Cons h t)`
// moves the only pointer-typed slot of the source's old ctor
// (slot 1, the tail; slot 0 is Int and never a dec candidate).
// The reuse arm therefore must emit NEITHER a per-field drop
// call nor an outer `ailang_rc_dec` against the source — only
// the unconditional tag store and field stores. The 18d.2 leak
// (full slot leaking under the reuse arm regardless of moves)
// is closed: the precise-skip behaviour matches what 18d.3's
// moved_slots side table records for `xs` here.
assert!(
!reuse_arm.contains("@drop_"),
"reuse arm must not call any `@drop_<m>_<T>` — both pointer slots are moved in this fixture. Reuse arm IR was:\n{reuse_arm}"
);
assert!(
!reuse_arm.contains("@ailang_rc_dec"),
"reuse arm must not call `@ailang_rc_dec` against the source (refcount-1 path keeps the slot alive in place). Reuse arm IR was:\n{reuse_arm}"
);
}
/// codegen actually emits `ailang_rc_dec` at end-of-scope
/// for trackable RC binders under `--alloc=rc`. This is the first
/// fixture where the `dec` path runs at runtime — `b` is bound to a
/// heap-allocated `MkBox`, the box is read via `match` (a borrow,
/// `consume_count == 0`), and the let scope closes before the fn
/// returns Unit. Codegen emits `call void @ailang_rc_dec(ptr %v1)`
/// after the match's join block; the box's refcount drops to 0 and
/// the underlying `malloc`'d block is freed by `runtime/rc.c`.
///
/// Properties guarded:
/// (1) the binary produces stdout `42` — i.e. dec does not free the
/// box BEFORE `match` reads its `Int` payload;
/// (2) the binary exits cleanly (exit code 0, no segfault) — i.e.
/// the dec call does not double-free or trip
/// `ailang_rc_dec`'s underflow guard.
///
/// Box has no boxed children, so the shallow-free contract of 18c.3's
/// `ailang_rc_dec` is sufficient. A recursive ADT (List, Tree) would
/// leak its boxed children silently — that's the 18c.4 scope, not
/// this iter.
#[test]
fn alloc_rc_emits_dec_for_unique_let_bound_box() {
let example = "rc_box_drop.ail";
let stdout_rc = build_and_run_with_alloc(example, "rc");
assert_eq!(stdout_rc.trim(), "42");
}
/// per-type drop fns + recursive `dec` cascade. Builds a
/// 5-element `IntList` and reduces it via `sum_list`. Under
/// `--alloc=rc` the codegen emits `define void @drop_<m>_IntList`
/// whose `Cons` arm recursively calls itself on the `tail` field —
/// the first iter where a recursive ADT under RC frees its tail
/// cells when their drop fn is invoked. Stdout is `15` (the sum
/// of `[1,2,3,4,5]`) and the binary must exit cleanly.
///
/// Note: in this fixture the binder `xs` has `consume_count == 1`
/// (passed to `sum_list`), so codegen does NOT emit a drop call at
/// `main`'s let-close — the cells stay leaked at process exit (the
/// caller / fn-param dec story is 18d). The fixture's purpose is to
/// (a) lock in the recursive-ADT IR-shape (covered in the codegen
/// IR-shape test below) and (b) confirm the program still runs
/// correctly. The companion `rc_list_drop_borrow` fixture exercises
/// the dec path itself.
#[test]
fn alloc_rc_recursive_list_sum() {
let example = "rc_list_drop.ail";
let stdout_rc = build_and_run_with_alloc(example, "rc");
assert_eq!(stdout_rc.trim(), "15");
}
/// exercise the recursive drop cascade at
/// runtime AND the arm-close pattern-binder dec.
///
/// 18c.4 originally used this fixture to lock in the recursive
/// `drop_<m>_IntList` cascade at `xs`'s let-close. 18d.3 introduced
/// move-tracking and shipped a documented memory-hygiene regression:
/// the `t` pattern-binder of `(Cons h t)` was unused (consume_count
/// == 0) and the `xs` partial-drop now skipped slot 1 (moved into
/// `t`), so the 4-cell tail leaked.
///
/// 18d.4 closes that regression by emitting an arm-close drop on
/// `t` itself (symmetric to the let-close drop emission seam). At
/// runtime the Cons arm now:
/// 1. Loads slot 0 (h: Int → primitive, no drop).
/// 2. Loads slot 1 (t: IntList → moved out of `xs`).
/// 3. Prints h.
/// 4. **NEW (18d.4):** calls
/// `@drop_rc_list_drop_borrow_IntList(ptr %t)` — recursively
/// cascades through the 4-cell tail, freeing each cell.
/// 5. Branches to the join.
///
/// And at `xs`'s let-close, the inlined partial drop skips slot 1
/// (already freed by the arm) and dec's the outer Cons cell.
///
/// Properties guarded:
/// 1. The binary runs to completion (no segfault from a
/// mishandled recursive cascade or refcount underflow).
/// 2. Stdout is `11` (the head of the 5-element list).
/// 3. The Cons arm's body emits the per-type drop on `t` between
/// the `print h` call and the arm's branch back to the match
/// join — the IR-shape signature of 18d.4's arm-close
/// pattern-binder dec.
#[test]
fn alloc_rc_borrow_only_recursive_list_drop() {
let example = "rc_list_drop_borrow.ail";
let stdout_rc = build_and_run_with_alloc(example, "rc");
assert_eq!(stdout_rc.trim(), "11");
// IR-shape assertion: the Cons arm of main's match emits a drop
// on `t` after lowering the body. We re-lower under
// rc and inspect main's IR — the arm sequence must contain
// call void @ai/print_int(...) (the `print h`)
// call void @drop_rc_list_drop_borrow_IntList(ptr %v...) (NEW: 18d.4 t-drop)
// br label %mjoin.<id> (arm-close branch)
// in that order, in the Cons arm's basic block.
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
let json_path = workspace.join("examples").join(example);
let ws = ailang_surface::load_workspace(&json_path).expect("load workspace");
let mut lifted_modules = std::collections::BTreeMap::new();
for (mname, m) in &ws.modules {
let desugared = ailang_core::desugar::desugar_module(m);
let lifted = ailang_check::lift_letrecs(&desugared)
.unwrap_or_else(|e| panic!("lift_letrecs in module `{mname}`: {e:?}"));
lifted_modules.insert(mname.clone(), lifted);
}
let lifted_ws = ailang_core::Workspace {
entry: ws.entry.clone(),
modules: lifted_modules,
root_dir: ws.root_dir.clone(),
registry: ws.registry.clone(),
};
// post-print-migration, fixtures use `(app print x)`
// which monomorphises to `print__<T>`. Adding the mono pass here
// mirrors `ail build`'s pipeline (desugar -> lift -> mono ->
// lower); without it, lowering errors with `UnknownVar("print")`.
let mono_ws = ailang_check::monomorphise_workspace(&lifted_ws)
.expect("monomorphise_workspace");
let ir = ailang_codegen::lower_workspace_with_alloc(
&mono_ws,
ailang_codegen::AllocStrategy::Rc,
)
.expect("lower workspace under rc");
let main_start = ir
.find("define i8 @ail_rc_list_drop_borrow_main(")
.expect("main definition present");
let main_end_offset = ir[main_start..]
.find("\n}\n")
.expect("main definition ends with `}`");
let main_body = &ir[main_start..main_start + main_end_offset];
// The arm-close drop must hit the per-type IntList drop fn (NOT
// the shallow `ailang_rc_dec` fallback for dynamic-tag binders
// with non-empty moved_slots — `t` has empty moved_slots in this
// fixture because the arm body doesn't re-scrutinise it).
assert!(
main_body.contains("call void @drop_rc_list_drop_borrow_IntList(ptr "),
"main's body must call `@drop_rc_list_drop_borrow_IntList(ptr ...)` for the unused Cons-arm `t` binder. main body was:\n{main_body}"
);
}
/// move-aware pattern bindings — let-close inlines a
/// per-field dec sequence that skips moved slots and dec's non-moved
/// (e.g. wildcarded) pointer-typed slots.
///
/// The fixture defines a `Pair (IntList, IntList)` and pattern-matches
/// it as `(Pair a _)` — the first slot is moved into `a` (consumed by
/// `sum_list a`), the second slot is wildcarded and remains owned by
/// the Pair box. At p's let-close, codegen replaces the uniform
/// `drop_<m>_Pair` call with an inline sequence:
///
/// 1. NO load+drop for slot 0 (offset 8) — it is in
/// `moved_slots["p"]` → skipped.
/// 2. YES load+drop for slot 1 (offset 16) via
/// `@drop_<m>_IntList(<slot1_v>)` — it is non-moved.
/// 3. YES `@ailang_rc_dec(<p>)` for the outer Pair box.
///
/// Properties guarded:
/// 1. The binary runs to completion under `--alloc=rc`.
/// 2. Stdout is `6` (the sum of the first list, [1,2,3]).
/// 3. The IR at p's let-close contains an inlined per-field drop
/// for slot 1 (the wildcarded IntList) but NOT the uniform
/// `drop_<m>_Pair` call against `p`.
#[test]
fn alloc_rc_partial_drop_skips_moved_keeps_wildcarded() {
let example = "pat_extract_partial_drop.ail";
let stdout_rc = build_and_run_with_alloc(example, "rc");
assert_eq!(stdout_rc.trim(), "6");
// Re-lower under rc to inspect the IR shape directly.
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
let json_path = workspace.join("examples").join(example);
let ws = ailang_surface::load_workspace(&json_path).expect("load workspace");
let mut lifted_modules = std::collections::BTreeMap::new();
for (mname, m) in &ws.modules {
let desugared = ailang_core::desugar::desugar_module(m);
let lifted = ailang_check::lift_letrecs(&desugared)
.unwrap_or_else(|e| panic!("lift_letrecs in module `{mname}`: {e:?}"));
lifted_modules.insert(mname.clone(), lifted);
}
let lifted_ws = ailang_core::Workspace {
entry: ws.entry.clone(),
modules: lifted_modules,
root_dir: ws.root_dir.clone(),
registry: ws.registry.clone(),
};
// post-print-migration, fixtures use `(app print x)`
// which monomorphises to `print__<T>`. Adding the mono pass here
// mirrors `ail build`'s pipeline (desugar -> lift -> mono ->
// lower); without it, lowering errors with `UnknownVar("print")`.
let mono_ws = ailang_check::monomorphise_workspace(&lifted_ws)
.expect("monomorphise_workspace");
let ir = ailang_codegen::lower_workspace_with_alloc(
&mono_ws,
ailang_codegen::AllocStrategy::Rc,
)
.expect("lower workspace under rc");
// The inline partial drop must dispatch through the IntList drop
// fn for the wildcarded slot 1. The presence of THIS specific
// call — `@drop_pat_extract_partial_drop_IntList(ptr ...)` — in
// main's body proves a non-moved pointer slot was dec'd inline.
assert!(
ir.contains("call void @drop_pat_extract_partial_drop_IntList(ptr "),
"expected an inline `@drop_pat_extract_partial_drop_IntList(ptr ...)` for the wildcarded slot 1 of `p`. IR was:\n{ir}"
);
// Conversely, the uniform Pair drop fn (which would cascade
// through BOTH slots — the wrong shape because slot 0 was
// moved) must NOT be called at p's let-close. The Pair drop
// fn is still EMITTED as a per-type definition (used by any
// potential ADT-recursive cascade), but the call site at
// let-close must be the inlined sequence.
let main_start = ir
.find("define i8 @ail_pat_extract_partial_drop_main(")
.expect("main definition present");
let main_end_offset = ir[main_start..]
.find("\n}\n")
.expect("main definition ends with `}`");
let main_body = &ir[main_start..main_start + main_end_offset];
assert!(
!main_body.contains("@drop_pat_extract_partial_drop_Pair("),
"main's body must not call the uniform `@drop_pat_extract_partial_drop_Pair(...)` — at let-close, the moved-slot-aware inline sequence replaces it. main body was:\n{main_body}"
);
assert!(
main_body.contains("call void @ailang_rc_dec(ptr "),
"main's body must call `@ailang_rc_dec(ptr ...)` for the outer Pair box at let-close. main body was:\n{main_body}"
);
}
/// Own-param dec at fn return. Symmetric to 18c.3/18c.4's
/// `Term::Let`-scope-close drop emission and 18d.4's arm-close
/// pattern-binder dec, fired at the lexical close of a fn body. The
/// `head_or_zero` fn declares its only parameter `xs` as
/// `(own (con IntList))` — the static "caller handed off ownership"
/// signal. Under `--alloc=rc`, codegen now emits a drop call against
/// the param SSA (`%arg_xs`) before the fn's `ret`.
///
/// Properties guarded:
/// 1. The binary runs to completion under `--alloc=rc` (no segfault
/// from a mishandled cascade or refcount underflow when the
/// cascade hits the moved tail).
/// 2. Stdout is `11` (the head of the 5-element list).
/// 3. `head_or_zero`'s body contains a drop call against
/// `%arg_xs` BEFORE the `ret i64`. The drop may be the
/// per-type `@drop_<m>_IntList(ptr %arg_xs)` (when no slots
/// were moved out of `xs` — not the canonical case here) OR
/// the inlined-partial fallback `@ailang_rc_dec(ptr %arg_xs)`
/// (the canonical case here, since the Cons arm pattern-binds
/// slot 1 into `t`, recording `moved_slots[xs] = {1}`). Either
/// shape is acceptable per the iter brief; what matters is
/// that ANY drop fires.
#[test]
fn alloc_rc_own_param_dec_at_fn_return() {
let example = "rc_own_param_drop.ail";
let stdout_rc = build_and_run_with_alloc(example, "rc");
assert_eq!(stdout_rc.trim(), "11");
// Re-lower under rc to inspect head_or_zero's IR shape.
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
let json_path = workspace.join("examples").join(example);
let ws = ailang_surface::load_workspace(&json_path).expect("load workspace");
let mut lifted_modules = std::collections::BTreeMap::new();
for (mname, m) in &ws.modules {
let desugared = ailang_core::desugar::desugar_module(m);
let lifted = ailang_check::lift_letrecs(&desugared)
.unwrap_or_else(|e| panic!("lift_letrecs in module `{mname}`: {e:?}"));
lifted_modules.insert(mname.clone(), lifted);
}
let lifted_ws = ailang_core::Workspace {
entry: ws.entry.clone(),
modules: lifted_modules,
root_dir: ws.root_dir.clone(),
registry: ws.registry.clone(),
};
// post-print-migration, fixtures use `(app print x)`
// which monomorphises to `print__<T>`. Adding the mono pass here
// mirrors `ail build`'s pipeline (desugar -> lift -> mono ->
// lower); without it, lowering errors with `UnknownVar("print")`.
let mono_ws = ailang_check::monomorphise_workspace(&lifted_ws)
.expect("monomorphise_workspace");
let ir = ailang_codegen::lower_workspace_with_alloc(
&mono_ws,
ailang_codegen::AllocStrategy::Rc,
)
.expect("lower workspace under rc");
// Locate head_or_zero's body in the IR.
let fn_start = ir
.find("define i64 @ail_rc_own_param_drop_head_or_zero(")
.expect("head_or_zero definition present");
let fn_end_offset = ir[fn_start..]
.find("\n}\n")
.expect("head_or_zero ends with `}`");
let fn_body = &ir[fn_start..fn_start + fn_end_offset];
// The Own-param drop must fire on `%arg_xs` somewhere in
// head_or_zero's body. Three acceptable shapes:
// - per-type drop `@drop_<m>_IntList(ptr %arg_xs)` (empty
// moved_slots case),
// - tag-conditional partial-drop
// `@partial_drop_<m>_IntList(ptr %arg_xs, i64 <mask>)`
// (Iter 18g.tidy.fu2: non-empty moved_slots, dynamic-tag),
// - shallow `@ailang_rc_dec(ptr %arg_xs)` (legacy fallback;
// the typechecker accepts non-ADT param types but moved_slots
// cannot populate for those, so this branch is dead in
// practice).
// The canonical fixture here hits the partial-drop shape: the
// Cons arm moves slot 1 into the arm-bound `t`, so
// moved_slots[xs] = {1} at fn return → mask=2.
let has_per_type_drop =
fn_body.contains("call void @drop_rc_own_param_drop_IntList(ptr %arg_xs)");
let has_partial_drop = fn_body
.contains("call void @partial_drop_rc_own_param_drop_IntList(ptr %arg_xs,");
let has_shallow_dec = fn_body.contains("call void @ailang_rc_dec(ptr %arg_xs)");
assert!(
has_per_type_drop || has_partial_drop || has_shallow_dec,
"head_or_zero's body must drop the Own-mode param `xs` (per-type, partial-drop, or shallow dec) before the fn's `ret`. Body was:\n{fn_body}"
);
// The drop must precede the fn's `ret`. Scope: any `ret i64 ...`
// in the body comes AFTER the drop call site. We check by
// splitting on the `ret` instruction and ensuring at least one
// drop call occurred before each ret.
//
// Implementation note: the body has a single `ret` (the join
// block's value) since `head_or_zero` returns Int unconditionally
// at the body root. The drop is emitted right before that ret
// by the Iter 18d.4 emit_fn pre-ret seam.
let ret_idx = fn_body.find("\n ret i64").expect("head_or_zero has a ret i64");
let pre_ret = &fn_body[..ret_idx];
let drop_in_pre_ret = pre_ret.contains("call void @drop_rc_own_param_drop_IntList(ptr %arg_xs)")
|| pre_ret
.contains("call void @partial_drop_rc_own_param_drop_IntList(ptr %arg_xs,")
|| pre_ret.contains("call void @ailang_rc_dec(ptr %arg_xs)");
assert!(
drop_in_pre_ret,
"head_or_zero's `%arg_xs` drop must appear BEFORE the `ret i64` instruction. Pre-ret slice was:\n{pre_ret}"
);
}
/// `(drop-iterative)` opt-in annotation. The fixture
/// declares `IntList` with `(drop-iterative)` and runs an N=1,000,000
/// cell list through `head_or_zero`. The fn signature is
/// `(own (con IntList))` — 18d.4 emits a drop on the param at fn
/// return; under 18e that drop dispatches into the worklist body of
/// `drop_<m>_<IntList>` (or, in the canonical case here, into the
/// arm-close drop on `t` since the Cons arm pattern-binds the tail).
/// Either way, freeing the 1M-cell chain runs the iterative loop in
/// `runtime/rc.c` instead of recursing 1M frames deep on the C
/// stack.
///
/// Why 1M and not 100K: at 100K the recursive cascade still fits
/// in Linux's 8MB default stack (~64B/frame). 1M overflows clean
/// without the annotation (SIGSEGV) and runs cleanly with it. The
/// test would be a false-negative — passing without the iterative
/// body — at 100K.
///
/// Properties guarded:
/// 1. `--alloc=rc` produces stdout `1\n` (the head of the
/// tail-recursively-built `[1, 2, ..., 1_000_000]`).
/// 2. The binary exits cleanly (status 0; no SIGSEGV from a
/// recursive cascade overflowing the C stack — which DOES
/// happen on the same fixture with the annotation removed,
/// hand-verified during 18e implementation).
#[test]
fn alloc_rc_drop_iterative_handles_million_cell_list() {
let example = "rc_drop_iterative_long_list.ail";
let stdout_rc = build_and_run_with_alloc(example, "rc");
assert_eq!(stdout_rc.trim(), "1");
}
/// IR-shape signature of `(drop-iterative)`. The drop fn
/// for the annotated type contains a worklist loop (`br label
/// %loop_head`, the runtime-helper calls, a backedge from each ctor
/// arm) and does NOT contain a recursive `call void @drop_<m>_<T>(...)`
/// against itself — the recursion has been replaced by worklist
/// dispatch.
#[test]
fn iter18e_drop_iterative_emits_worklist_body_no_self_recursion() {
let example = "rc_drop_iterative_long_list.ail";
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
let json_path = workspace.join("examples").join(example);
let ws = ailang_surface::load_workspace(&json_path).expect("load workspace");
let mut lifted_modules = std::collections::BTreeMap::new();
for (mname, m) in &ws.modules {
let desugared = ailang_core::desugar::desugar_module(m);
let lifted = ailang_check::lift_letrecs(&desugared)
.unwrap_or_else(|e| panic!("lift_letrecs in module `{mname}`: {e:?}"));
lifted_modules.insert(mname.clone(), lifted);
}
let lifted_ws = ailang_core::Workspace {
entry: ws.entry.clone(),
modules: lifted_modules,
root_dir: ws.root_dir.clone(),
registry: ws.registry.clone(),
};
// post-print-migration, fixtures use `(app print x)`
// which monomorphises to `print__<T>`. Adding the mono pass here
// mirrors `ail build`'s pipeline (desugar -> lift -> mono ->
// lower); without it, lowering errors with `UnknownVar("print")`.
let mono_ws = ailang_check::monomorphise_workspace(&lifted_ws)
.expect("monomorphise_workspace");
let ir = ailang_codegen::lower_workspace_with_alloc(
&mono_ws,
ailang_codegen::AllocStrategy::Rc,
)
.expect("lower workspace under rc");
// Locate the drop fn body. There is exactly one
// `define void @drop_rc_drop_iterative_long_list_IntList(`.
let drop_start = ir
.find("define void @drop_rc_drop_iterative_long_list_IntList(")
.expect("drop fn definition present");
let drop_end_offset = ir[drop_start..]
.find("\n}\n")
.expect("drop fn ends with `}`");
let drop_body = &ir[drop_start..drop_start + drop_end_offset];
// Worklist signatures: backedge label, push, pop, free, all
// present in the body.
assert!(
drop_body.contains("br label %loop_head"),
"iterative drop body must contain a `br label %loop_head` backedge. Body was:\n{drop_body}"
);
assert!(
drop_body.contains("call ptr @ailang_drop_worklist_new()"),
"iterative drop body must call ailang_drop_worklist_new. Body was:\n{drop_body}"
);
assert!(
drop_body.contains("call ptr @ailang_drop_worklist_pop(ptr %wl)"),
"iterative drop body must call ailang_drop_worklist_pop. Body was:\n{drop_body}"
);
assert!(
drop_body.contains("call void @ailang_drop_worklist_push(ptr %wl,"),
"iterative drop body must call ailang_drop_worklist_push for same-type fields. Body was:\n{drop_body}"
);
assert!(
drop_body.contains("call void @ailang_drop_worklist_free(ptr %wl)"),
"iterative drop body must call ailang_drop_worklist_free at finish. Body was:\n{drop_body}"
);
// Critical negative: NO recursive self-call in the drop body —
// the whole point of 18e is that the recursion was replaced by
// worklist dispatch. (Calls to other drop fns are still allowed,
// since cross-type ADT fields go through the regular
// `field_drop_call` path.)
assert!(
!drop_body.contains(
"call void @drop_rc_drop_iterative_long_list_IntList("
),
"iterative drop body must NOT recursively call itself — the recursion was replaced by worklist dispatch. Body was:\n{drop_body}"
);
}
/// control — the same fixture WITHOUT the
/// `(drop-iterative)` annotation must still emit the recursive
/// 18c.4 body. We synthesise the unannotated module on the fly
/// (mutating the workspace's parsed AST) so this test is independent
/// of any on-disk fixture changes.
#[test]
fn iter18e_no_annotation_keeps_recursive_drop_body() {
let example = "rc_drop_iterative_long_list.ail";
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
let json_path = workspace.join("examples").join(example);
let ws = ailang_surface::load_workspace(&json_path).expect("load workspace");
// Mutate every TypeDef in every module to clear the annotation.
let mut modules = std::collections::BTreeMap::new();
for (mname, m) in &ws.modules {
let mut mc = m.clone();
for d in &mut mc.defs {
if let ailang_core::ast::Def::Type(td) = d {
td.drop_iterative = false;
}
}
let desugared = ailang_core::desugar::desugar_module(&mc);
let lifted = ailang_check::lift_letrecs(&desugared)
.unwrap_or_else(|e| panic!("lift_letrecs in module `{mname}`: {e:?}"));
modules.insert(mname.clone(), lifted);
}
let lifted_ws = ailang_core::Workspace {
entry: ws.entry.clone(),
modules,
root_dir: ws.root_dir.clone(),
registry: ws.registry.clone(),
};
// post-print-migration, fixtures use `(app print x)`
// which monomorphises to `print__<T>`. Adding the mono pass here
// mirrors `ail build`'s pipeline (desugar -> lift -> mono ->
// lower); without it, lowering errors with `UnknownVar("print")`.
let mono_ws = ailang_check::monomorphise_workspace(&lifted_ws)
.expect("monomorphise_workspace");
let ir = ailang_codegen::lower_workspace_with_alloc(
&mono_ws,
ailang_codegen::AllocStrategy::Rc,
)
.expect("lower workspace under rc");
let drop_start = ir
.find("define void @drop_rc_drop_iterative_long_list_IntList(")
.expect("drop fn definition present");
let drop_end_offset = ir[drop_start..]
.find("\n}\n")
.expect("drop fn ends with `}`");
let drop_body = &ir[drop_start..drop_start + drop_end_offset];
// The 18c.4 recursive shape signature: a self-call AND no
// worklist plumbing.
assert!(
drop_body.contains(
"call void @drop_rc_drop_iterative_long_list_IntList(ptr %v"
),
"without (drop-iterative), the body must still recursively cascade through the tail. Body was:\n{drop_body}"
);
assert!(
!drop_body.contains("ailang_drop_worklist_new"),
"without (drop-iterative), the body must not call worklist runtime helpers. Body was:\n{drop_body}"
);
}
/// pattern-binder dec at arm close (Iter A in
/// the 18d.4 emission seam) was firing on pattern-bound pointer
/// fields whose enclosing fn is Implicit-mode. The dec freed memory
/// the caller still referenced, producing refcount underflow or
/// SIGSEGV.
///
/// The fixture's shape — a heap-allocated `t` shared between a
/// pattern-destructuring callee (`pin`) and a recursive call that
/// re-passes `t` (`loop`) — is the canonical case where the bug
/// fires. `pin` does not consume the pattern-bound children (`l`,
/// `r`); pre-fix codegen emitted `drop_<m>_<Tree>(l)` and
/// `drop_<m>_<Tree>(r)` at arm close, dec'ing pointers that lived
/// inside `t`'s box, which the outer `loop`'s next iteration still
/// expects to be valid.
///
/// Properties guarded:
/// (1) The binary exits cleanly under `--alloc=rc` (no SIGSEGV
/// from a use-after-free, no abort from
/// `ailang_rc_dec: refcount underflow` in `runtime/rc.c`).
/// (2) Stdout is `0` (the implicit-mode pinned-recursion fixture
/// prints the recursion result, not a pointer/payload).
///
/// Pre-fix this test fails with exit code 139 (SIGSEGV) under rc.
/// Post-fix `--alloc=rc` produces clean output. Kept as
/// regression coverage — see CLAUDE.md "Bug fixes — TDD, always".
#[test]
fn alloc_rc_pattern_bind_in_implicit_fn_does_not_dec_borrowed_children() {
let example = "rc_pin_recurse_implicit.ail";
let stdout_rc = build_and_run_with_alloc(example, "rc");
assert_eq!(stdout_rc.trim(), "0");
}
/// build the example under `--alloc=rc`, run with
/// `AILANG_RC_STATS=1`, and return the parsed `(allocs, frees, live)`
/// triple from the runtime's atexit summary on stderr. The summary
/// line shape is fixed by `runtime/rc.c`'s `ailang_rc_stats_atexit`:
///
/// ailang_rc_stats: allocs=N frees=M live=K
///
/// Tests that need to assert allocation accounting (leaks, prompt-
/// drop coverage, refcount-based correctness) consume this triple
/// directly. `live == 0` at exit is the leak-free invariant for
/// fixtures whose `main` body returns Int/Unit; fixtures that
/// intentionally leak (e.g. Implicit-mode coverage) are gated to a
/// non-zero expected value naming the leak.
fn build_and_run_with_rc_stats(example: &str) -> (String, u64, u64, i64) {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
let src = workspace.join("examples").join(example);
let tmp = std::env::temp_dir().join(format!(
"ailang_e2e_rcstats_{}_{}",
example.replace('.', "_"),
std::process::id()
));
std::fs::create_dir_all(&tmp).unwrap();
let out = tmp.join("bin");
let status = Command::new(ail_bin())
.args([
"build",
src.to_str().unwrap(),
"--alloc=rc",
"-o",
])
.arg(&out)
.status()
.expect("ail build failed to run");
assert!(status.success(), "ail build --alloc=rc failed for {example}");
let output = Command::new(&out)
.env("AILANG_RC_STATS", "1")
.output()
.expect("execute binary");
assert!(
output.status.success(),
"binary {} (--alloc=rc, AILANG_RC_STATS=1) exited non-zero",
out.display()
);
let stdout = String::from_utf8(output.stdout).expect("stdout utf8");
let stderr = String::from_utf8(output.stderr).expect("stderr utf8");
let line = stderr
.lines()
.find(|l| l.starts_with("ailang_rc_stats:"))
.unwrap_or_else(|| {
panic!("missing ailang_rc_stats line in stderr; stderr was:\n{stderr}")
});
let mut allocs: Option<u64> = None;
let mut frees: Option<u64> = None;
let mut live: Option<i64> = None;
for tok in line.split_whitespace() {
if let Some(v) = tok.strip_prefix("allocs=") {
allocs = v.parse().ok();
} else if let Some(v) = tok.strip_prefix("frees=") {
frees = v.parse().ok();
} else if let Some(v) = tok.strip_prefix("live=") {
live = v.parse().ok();
}
}
(
stdout,
allocs.expect("missing allocs= field"),
frees.expect("missing frees= field"),
live.expect("missing live= field"),
)
}
/// explicit-mode tail-recursive list-sum must
/// not leak the LCons outer cells.
///
/// Background: 18f.2's tail-latency bench found that
/// `bench_latency_explicit.ail` peaks at the same RSS as the
/// implicit-mode variant despite carrying mode annotations
/// throughout the hot path. Diagnosis: in
///
/// (case (LCons h t) (tail-app sum_acc t (...)))
///
/// the pattern-binder `t` is consumed into the tail-call
/// (`consume_count > 0`), so 18d.4 Iter A skips the arm-close drop;
/// the outer LCons cell is now a husk (its only ptr field `t` was
/// moved out) but no drop site emits the shallow `ailang_rc_dec`
/// for it. One outer cell leaks per consumed list element.
///
/// Pre-fix on this fixture: `live = 100` (one LCons leak per
/// element of the 100-cell list).
/// Post-fix: `live = 0` (LNil + LCons cells all reach refcount
/// zero before exit).
///
/// Kept as regression coverage. The `(drop-iterative)` annotation
/// on IntList means the freeing happens via the worklist allocator,
/// not direct recursion — both paths must accumulate frees.
#[test]
fn alloc_rc_explicit_mode_tail_sum_does_not_leak_outer_cells() {
let (stdout, allocs, frees, live) =
build_and_run_with_rc_stats("rc_tail_sum_explicit_leak.ail");
assert_eq!(
stdout.trim(),
"4950",
"sum 0..99 must equal 4950 regardless of leak status"
);
assert_eq!(
live, 0,
"explicit-mode tail-sum leaks {live} cells (allocs={allocs} frees={frees}); \
18d.4 Iter A skips the arm-close drop because t is consumed into the tail-call, \
and no drop site emits the shallow rc_dec for the moved-from outer LCons cell"
);
}
/// a `let`-binder whose value is the result of
/// an Own-returning function call must be dropped at let-scope close.
///
/// Background: 18c.3's `is_rc_heap_allocated` returns `false` for any
/// `Term::App` shape (the doc-comment explicitly defers the
/// owned-returning-call case to "later iters tied to `(own)` ret-mode
/// contracts"). With Iter 18a's mode contracts in place, that
/// limitation is no longer load-bearing — the call's `ret_mode == Own`
/// is the static signal that a fresh heap allocation has flowed into
/// the binder, and the let-scope close is the right place to dec it.
///
/// 18f.2's bench surfaced the user-visible cost: in
/// `(let t (app build_tree 19) ...)`, `t` was not trackable and the
/// depth-19 Tree (~1M cells) leaked at every program exit.
///
/// Pre-fix on this minimal fixture: `live = 3` (one TNode + two TLeaf
/// children).
/// Post-fix: `live = 0`.
#[test]
fn alloc_rc_let_binder_for_owned_returning_app_drops_at_scope_close() {
let (stdout, allocs, frees, live) =
build_and_run_with_rc_stats("rc_let_owned_app_leak.ail");
assert_eq!(
stdout.trim(),
"1",
"pin(TNode) returns 1 regardless of leak status"
);
assert_eq!(
live, 0,
"let-binder for an Own-returning app leaked {live} cells \
(allocs={allocs} frees={frees}); is_rc_heap_allocated must \
recognise Term::App with ret_mode=Own as trackable, and the \
drop-symbol resolution must derive drop_<m>_<T> from the \
call's return type"
);
}
/// a let-binder whose value is the
/// result of an `Implicit`-ret-mode (default, unannotated) call must
/// NOT be trackable. Implicit is the back-compat lane that 18c.3
/// documented as "params don't get any dec — leak rather than mis-
/// dec"; the symmetric carve-out for ret-modes is "Implicit-returning
/// calls do not flow ownership to the caller, the caller must NOT
/// dec".
///
/// If `is_rc_heap_allocated` were mistakenly to fire on this shape,
/// the let-scope close would emit an unjustified dec — refcount
/// underflow at runtime (the cell's RC reaches zero before any other
/// owner had a chance to dec). The fixture under `--alloc=rc` must:
///
/// (1) Build cleanly (no consume-while-borrowed false positive,
/// parse error, or unimplemented).
/// (2) Exit cleanly with stdout `7` — the unboxed Int from the cell.
/// (3) Report `live = 1` — the one MkT cell leaks. The leak is the
/// intentional Implicit-mode behaviour; if codegen ever started
/// dec'ing this shape it would crash with refcount underflow
/// before the leak number could even be observed.
///
/// This test is the negative-side companion to
/// `alloc_rc_let_binder_for_owned_returning_app_drops_at_scope_close`:
/// the asymmetry between (own)-ret-mode (live=0) and Implicit-ret-mode
/// (live=1) is the documented contract.
#[test]
fn alloc_rc_let_binder_for_implicit_returning_app_does_not_drop() {
let (stdout, allocs, frees, live) =
build_and_run_with_rc_stats("rc_let_implicit_returning_app.ail");
assert_eq!(stdout.trim(), "7", "alloc(7) -> unbox should print 7");
assert_eq!(
live, 1,
"Implicit-ret-mode App must leak (not crash with refcount \
underflow); allocs={allocs} frees={frees} live={live}. \
A live count of 0 means is_rc_heap_allocated mistakenly \
marked Implicit-ret-mode App as trackable; a non-zero exit \
means the unjustified dec triggered the underflow guard."
);
}
/// let-alias-aware mode propagation.
///
/// 18d.4 Iter A's fix gated arm-close pattern-binder dec on the
/// scrutinee's `current_param_modes` lookup, but only fn-params
/// register there. A let-binder whose value is `Term::Var` aliasing
/// a non-Own fn-param defeats the gate: the lookup misses, the
/// default treats it as owned, and the arm-close drop fires on
/// pattern-binders whose underlying memory is still owned by the
/// caller. The fixture's shape:
///
/// (fn pin_aliased (params t) ; t: Implicit-mode (default)
/// (let a t ; a aliases t
/// (match a ; matched-on alias
/// (case (TLeaf) 0)
/// (case (TNode v l r) 1))))
///
/// Pre-fix on a recursive caller passing the same heap value: the
/// arm-close drop fragments the caller's tree, the recursive call
/// re-loads now-freed cells, and the binary aborts (SIGSEGV / RC
/// underflow).
///
/// Post-fix: `current_param_modes` propagates through `Term::Let
/// { value: Term::Var(p), ... }` for the duration of the let body,
/// so a let-aliased Implicit / Borrow scrutinee correctly skips
/// the arm-close drop. Stdout is `0`.
#[test]
fn alloc_rc_let_alias_of_implicit_param_does_not_dec_borrowed_children() {
let example = "rc_let_alias_implicit_param.ail";
let stdout_rc = build_and_run_with_alloc(example, "rc");
assert_eq!(stdout_rc.trim(), "0");
}
/// Regression — RED-then-GREEN for the dynamic-tag partial-drop
/// carve-out at fn-return.
///
/// Three carve-out sites previously fell back to a shallow
/// `ailang_rc_dec` when a binder's `moved_slots` was non-empty
/// and the runtime ctor tag was dynamic (not statically known
/// from a `Term::Ctor` value). The shallow dec frees the outer
/// cell but does not walk the unmoved ptr fields — those leak
/// silently.
///
/// **Site 1 (this fixture): Iter B Own-param dec at fn return
/// (lib.rs).**
///
/// (fn use_first
/// (params (own (con Pair)))
/// (body
/// (match p
/// (case (pat-ctor MkPair a _) 1))))
///
/// `p` is consumed by the match. Its `consume_count == 0`,
/// `mode == Own`, and `moved_slots[p] = {0}` (slot 0 was bound,
/// slot 1 wildcarded). At fn-return, Iter B's dynamic-tag fallback
/// fired shallow `ailang_rc_dec(p)`, freeing p's outer Pair but
/// leaking the unbound second `MkCell` and its two `MkWrap`
/// children — three cells.
///
/// fu2 routes the carve-out through `partial_drop_<m>_Pair(p,
/// mask)`, which dispatches on the runtime tag and dec's slot 1
/// (the unmoved field) via `drop_<m>_Cell`, cascading correctly.
///
/// Pre-fix: `live = 3`. Post-fix: `live = 0`.
#[test]
fn alloc_rc_partial_drop_at_fn_return_does_not_leak_unmoved_fields() {
let (stdout, allocs, frees, live) =
build_and_run_with_rc_stats("rc_own_param_partial_drop_leak.ail");
assert_eq!(
stdout.trim(),
"1",
"use_first returns 1 regardless of leak status"
);
assert_eq!(
live, 0,
"Own-param fn-return partial-drop carve-out leaked {live} cells \
(allocs={allocs} frees={frees}); fu2 must route the dynamic-tag \
fallback through `partial_drop_<m>_<T>(p, mask)` so the unmoved \
slots dec via the per-type cascade rather than being shallow-freed"
);
}
/// Arm-close pattern-binder dec (match_lower.rs).
///
/// An outer match's arm-bound pattern-binder may itself be the
/// scrutinee of an inner match that moves out some of its fields.
/// At outer-arm close, `moved_slots[bname]` is non-empty and
/// `consume_count[bname] == 0` — the carve-out previously emitted
/// a shallow `ailang_rc_dec`, leaking the inner ctor's unbound
/// (wildcarded) ptr fields.
///
/// (match p
/// (case (pat-ctor MkPair a b)
/// (match a
/// (case (pat-ctor MkCell w1 _) 1))))
///
/// `a` has `moves={0}` (w1 bound, slot 1 of MkCell wildcarded).
/// Pre-fix: shallow dec on `a` left slot 1's `MkWrap(2)` cell
/// allocated.
/// Post-fix: `partial_drop_<m>_Cell(a, mask=001)` dec's slot 1.
///
/// Pre-fix: `live = 1`. Post-fix: `live = 0`.
#[test]
fn alloc_rc_partial_drop_at_arm_close_does_not_leak_unmoved_fields() {
let (stdout, allocs, frees, live) =
build_and_run_with_rc_stats("rc_match_arm_partial_drop_leak.ail");
assert_eq!(
stdout.trim(),
"1",
"use_first returns 1 regardless of leak status"
);
assert_eq!(
live, 0,
"match-arm pattern-binder partial-drop carve-out leaked {live} cells \
(allocs={allocs} frees={frees}); fu2 must route arm-bound binders \
with non-empty moved_slots through `partial_drop_<m>_<T>(b, mask)`"
);
}
/// let-close for an
/// App-bound binder (drop.rs `emit_inlined_partial_drop`
/// non-Ctor branch).
///
/// `emit_inlined_partial_drop` was keyed against `Term::Ctor`
/// values; for `Term::App` (Own-returning) values its fallback
/// shallow-dec'd the binder. With pattern-matching of the
/// App-bound binder (`(let p (app build_pair) (match p ...))`),
/// the body's `moved_slots[p]` is non-empty but the binder's
/// runtime tag is dynamic (unknown from the App's surface).
///
/// fu2 derives the type from `synth_arg_type(value)` and routes
/// through `partial_drop_<m>_<T>(p, mask)`.
///
/// Pre-fix: `live = 3` (slot 1's whole MkCell + two MkWraps).
/// Post-fix: `live = 0`.
#[test]
fn alloc_rc_partial_drop_at_app_let_close_does_not_leak_unmoved_fields() {
let (stdout, allocs, frees, live) =
build_and_run_with_rc_stats("rc_app_let_partial_drop_leak.ail");
assert_eq!(
stdout.trim(),
"1",
"use_cell returns 1 regardless of leak status"
);
assert_eq!(
live, 0,
"App-bound let-close partial-drop carve-out leaked {live} cells \
(allocs={allocs} frees={frees}); fu2 must derive the binder's \
static type from synth_arg_type(value) and route through \
`partial_drop_<m>_<T>(p, mask)`"
);
}
/// `ail merge-prose` reads two files and prints a prompt
/// that frames the prose-round-trip task for an external LLM. Smoke
/// test: writes two temp inputs, invokes the binary, asserts exit 0
/// and that the captured stdout carries the role-statement landmark
/// plus both inputs verbatim.
#[test]
fn merge_prose_prints_framed_prompt() {
let tmp = std::env::temp_dir().join(format!(
"ailang_e2e_merge_prose_{}",
std::process::id()
));
std::fs::create_dir_all(&tmp).unwrap();
let orig_path = tmp.join("orig.ail.json");
let prose_path = tmp.join("edited.prose.txt");
// merge-prose now loads the module via ailang_core,
// renders to Form-A, and embeds *that* in the prompt — so the
// input must be a well-formed ailang/v0 JSON-AST that
// load_module accepts. The schema, name, and an empty defs list
// are the minimum.
let orig_bytes =
b"{\"schema\":\"ailang/v0\",\"name\":\"foo\",\"imports\":[],\"defs\":[]}";
let prose_bytes = b"// module foo\n\nfn bar() -> Int { 42 }\n";
std::fs::write(&orig_path, orig_bytes).unwrap();
std::fs::write(&prose_path, prose_bytes).unwrap();
let output = Command::new(ail_bin())
.args(["merge-prose"])
.arg(&orig_path)
.arg(&prose_path)
.output()
.expect("ail merge-prose failed to run");
assert!(
output.status.success(),
"ail merge-prose exited non-zero: stderr={}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).expect("stdout utf8");
assert!(!stdout.is_empty(), "empty stdout from merge-prose");
assert!(
stdout.contains("integrating prose edits"),
"prompt missing role-statement landmark; got:\n{stdout}"
);
// Original module is now embedded as Form-A, not JSON.
assert!(
stdout.contains("(module foo"),
"prompt missing original Form-A content; got:\n{stdout}"
);
assert!(
stdout.contains("fn bar() -> Int { 42 }"),
"prompt missing edited prose content"
);
// The embedded spec must be present.
assert!(
stdout.contains("FORM-A SPECIFICATION"),
"prompt missing FORM-A SPECIFICATION section"
);
}
// ---------- ext-cli.1: CLI accepts `.ail` (Form A) source files ----------
fn workspace_root() -> std::path::PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.to_path_buf()
}
/// Property: `ail check` accepts a Form-A `.ail` source file (not just
/// the Form-B `.ail.json` canonical form) and exits zero on a
/// well-formed program. Guards against the post-rename misleading-JSON
/// fall-through that motivated iter ext-cli.1: before the rewiring,
/// `ail check examples/hello.ail` produced
/// `json: expected value at line 1 column 1` and exited non-zero.
#[test]
fn ail_check_accepts_ail_source() {
let example = workspace_root().join("examples/hello.ail");
assert!(example.is_file(), "fixture exists at {}", example.display());
let output = Command::new(ail_bin())
.args(["check", example.to_str().unwrap()])
.output()
.expect("run ail check");
assert!(
output.status.success(),
"ail check exited non-zero on .ail source:\n stdout: {}\n stderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
}
/// Property: a program built from `.ail` (Form A) produces the same
/// stdout as the same program built from its `.ail.json` (Form B)
/// counterpart. Guards against the surface→core dispatch silently
/// rewriting the loaded `Module` (e.g. losing imports, dropping defs,
/// re-ordering fields) — the binary semantics must be form-agnostic.
///
/// Form-A migration (iter form-a.1): only the eight carve-out fixtures
/// keep `.ail.json` form on disk post-iter. To preserve the dual-form
/// property test, derive a parallel `.ail.json` in a tempdir from
/// `hello.ail` via `ail parse`, then build and run both forms.
#[test]
fn ail_run_accepts_ail_source_with_same_stdout_as_ail_json() {
let ail_path = workspace_root().join("examples/hello.ail");
assert!(ail_path.is_file());
// The loader name-checks the module name against the file stem, so
// the derived JSON must be named `hello.ail.json` even in the temp
// location. Use a per-process tempdir to avoid races.
let tmpdir = std::env::temp_dir().join(format!(
"ailang_dual_form_{}",
std::process::id()
));
std::fs::create_dir_all(&tmpdir).unwrap();
let json_path = tmpdir.join("hello.ail.json");
let parse_status = Command::new(ail_bin())
.args([
"parse",
ail_path.to_str().unwrap(),
"-o",
json_path.to_str().unwrap(),
])
.status()
.expect("ail parse failed to run");
assert!(parse_status.success(), "ail parse failed on hello.ail");
assert!(json_path.is_file());
let build_and_capture = |src: &Path| -> Vec<u8> {
let tmp = std::env::temp_dir().join(format!(
"ailang_ext_cli_e2e_{}_{}",
src.file_name().unwrap().to_string_lossy().replace('.', "_"),
std::process::id()
));
std::fs::create_dir_all(&tmp).unwrap();
let out = tmp.join("bin");
let status = Command::new(ail_bin())
.args(["build", src.to_str().unwrap(), "-o"])
.arg(&out)
.status()
.expect("ail build failed to run");
assert!(status.success(), "ail build failed for {}", src.display());
Command::new(&out)
.output()
.expect("execute binary")
.stdout
};
let stdout_ail = build_and_capture(&ail_path);
let stdout_json = build_and_capture(&json_path);
assert_eq!(
stdout_ail, stdout_json,
"both forms must produce identical stdout"
);
}
/// `int_to_str(42)` prints "42\n" through the standard
/// `do io/print_str(...)` path. Smoke pin for the heap-Str builtin
/// wired in hs.4 — exercises checker (signature install), codegen
/// (lower_app arm + IR-header declare + is_static_callee whitelist),
/// runtime (str.c's `ailang_int_to_str` allocates a heap-Str slab,
/// @fputs reads from the bytes pointer at offset 8 with @stdout as
/// the FILE*), and the
/// unconditional `runtime/rc.c` link (str.c's weak extern of
/// `ailang_rc_alloc` resolves to the strong rc.c definition under
/// every alloc strategy after the hs.4 hoist).
#[test]
fn int_to_str_smoke() {
let out = build_and_run("int_to_str_smoke.ail");
assert_eq!(out, "42\n");
}
/// `float_to_str(3.5)` prints a libc-%g-conformant
/// rendering of 3.5. The runtime's `ailang_float_to_str` uses
/// `snprintf(..., "%g", x)` with no locale call, so the C locale
/// default applies and the dev target's glibc produces the three
/// bytes `3.5` (no trailing zeros). If a future target's libc or
/// locale produces a different rendering, this assertion is the
/// trip-wire that surfaces it. Note: post-Gitea-#7 the runtime
/// also applies a `.0`-fallback when `%g`'s output for a finite
/// double contains neither `.` nor `e`/`E` (mirroring the surface
/// printer's `write_float_lit`); `3.5` already contains `.`, so
/// the fallback does not fire here and the golden stays at the
/// three bytes `3.5`.
#[test]
fn float_to_str_smoke() {
let out = build_and_run("float_to_str_smoke.ail");
assert_eq!(out, "3.5\n");
}
/// Heap-Str RC-discipline: `int_to_str(42)` bound to a let, consumed
/// by `io/print_str`, must reach `live == 0` at program exit — every
/// heap-Str allocation matched by a drop. RED pin for the
/// effect-op-arg-mode gap surfaced by hs.4 (the uniqueness analyser
/// walks `Term::Do` args as `Position::Consume`; combined with
/// `EffectOpSig`'s missing `param_modes` field, the let-binder for
/// the `int_to_str` result is not trackable and no scope-close
/// `ailang_rc_dec` is emitted). The fix is on the planning queue;
/// this test fails until it lands, locking in the invariant.
#[test]
fn int_to_str_drop_balances_rc_stats() {
let (stdout, allocs, frees, live) =
build_and_run_with_rc_stats("int_to_str_drop_rc.ail");
assert_eq!(stdout, "42\n");
assert!(allocs >= 1, "expected at least one heap-Str allocation; got allocs={allocs}");
assert_eq!(allocs, frees, "every heap-Str alloc must be freed; allocs={allocs}, frees={frees}");
assert_eq!(live, 0, "no leaked heap-Str slabs allowed; live={live}");
}
/// Heap-Str RC-discipline through an ADT: `Box(int_to_str(42))`
/// constructs (ADT cell + heap-Str slab allocs), gets pattern-matched
/// to extract the inner Str, both reach `live == 0` at program exit.
/// RED pin for the same effect-op-arg-mode gap as
/// `int_to_str_drop_balances_rc_stats`; the outer ADT cell leaks via
/// the same `Implicit`-ret-mode path applied to its own let-binder.
#[test]
fn str_field_in_adt_drops_heap_str_correctly() {
let (stdout, allocs, frees, live) =
build_and_run_with_rc_stats("str_field_in_adt_heap.ail");
assert_eq!(stdout, "42\n");
assert!(allocs >= 2, "expected ADT cell + heap-Str slab; got allocs={allocs}");
assert_eq!(allocs, frees, "every RC slab must be freed; allocs={allocs}, frees={frees}");
assert_eq!(live, 0, "no leaked RC slabs allowed; live={live}");
}
/// primitive-Int passed to the polymorphic
/// `print` helper. Pre-per-type-print-retirement the fixture used `(do io/print_int n)`
/// directly and the heap-Str-ABI "Term::Do args = Borrow" rule meant
/// zero RC traffic on the unboxed Int (allocs == 0, frees == 0).
/// Post-per-type-print-retirement, `print n` desugars through `Show Int.show` →
/// `int_to_str` (heap-Str alloc, `ret_mode: Own`) → `io/print_str`
/// → slab drops at scope close. Pin shifts to the new canonical
/// post-iter shape: exactly one heap-Str slab cycle per `print`
/// of a non-Str value. The let-binder property the original test
/// pinned (Term::Do args borrow-walk correctly on primitives) is
/// preserved by the surviving `io/print_str s` step inside
/// `print__Int.body`; this test now pins the slab-cycle pattern.
#[test]
fn int_arg_to_effect_op_does_not_rc_track() {
let (stdout, allocs, frees, live) =
build_and_run_with_rc_stats("int_to_print_int_borrow.ail");
assert_eq!(stdout, "7\n");
// After the per-type-print-op retirement, `print n` for n : Int
// allocates one heap-Str slab via Show Int → int_to_str, frees
// it at scope close. Spec §E.
assert_eq!(allocs, 1, "expected exactly one Show-Int heap-Str slab; got allocs={allocs}");
assert_eq!(frees, 1, "expected exactly one free; got frees={frees}");
assert_eq!(live, 0, "no leaked RC slabs allowed; live={live}");
}
/// heap-Str let-binder consumed by two `io/print_str`
/// calls in sequence. Pins the linearity-side consequence of the new
/// rule: under the old Position::Consume walk for Term::Do args, the
/// second print would have triggered `use-after-consume`. Under the
/// new Position::Borrow walk, both calls are borrows and the program
/// typechecks. Under --alloc=rc the slab is allocated once by
/// int_to_str, lives through both prints, and is freed exactly once
/// at scope close — allocs == 1, frees == 1, live == 0.
#[test]
fn heap_str_repeated_print_balances_rc_stats() {
let (stdout, allocs, frees, live) =
build_and_run_with_rc_stats("heap_str_repeated_print_borrow.ail");
assert_eq!(stdout, "42\n42\n");
assert_eq!(allocs, 1, "expected exactly one heap-Str slab; got allocs={allocs}");
assert_eq!(frees, 1, "expected exactly one free; got frees={frees}");
assert_eq!(live, 0, "no leaked heap-Str slabs allowed; live={live}");
}
/// `bool_to_str(true)` bound to a let, consumed by
/// `io/print_str`. The heap-Str slab from `ailang_bool_to_str`
/// rides the same rc_header + ailang_rc_dec path as int_to_str's
/// output — `allocs == 1, frees == 1, live == 0` and stdout is
/// `true\n`. Companion of `bool_to_str_emits_false_branch` which
/// covers the false-branch stdout.
#[test]
fn bool_to_str_drop_balances_rc_stats() {
let (stdout, allocs, frees, live) =
build_and_run_with_rc_stats("bool_to_str_drop_rc.ail");
assert_eq!(stdout, "true\n");
assert_eq!(allocs, 1, "expected exactly one heap-Str slab; got allocs={allocs}");
assert_eq!(frees, 1, "expected exactly one free; got frees={frees}");
assert_eq!(live, 0, "no leaked heap-Str slabs allowed; live={live}");
}
/// stdout-smoke for the true branch — same fixture as
/// `bool_to_str_drop_balances_rc_stats` but checked here without
/// the RC-stats overhead. Pins the byte content of `ailang_bool_to_str`'s
/// "true" slab.
#[test]
fn bool_to_str_emits_true_branch() {
let out = build_and_run("bool_to_str_drop_rc.ail");
assert_eq!(out, "true\n");
}
/// stdout-smoke for the false branch. Pins the byte
/// content of `ailang_bool_to_str`'s "false" slab.
#[test]
fn bool_to_str_emits_false_branch() {
let out = build_and_run("bool_to_str_smoke_false.ail");
assert_eq!(out, "false\n");
}
/// `str_clone("hello")` bound to a let, consumed by
/// `io/print_str`. The heap-Str clone allocates a fresh slab (via
/// str_alloc) and the let-binder drops it at scope close.
/// `allocs == 1, frees == 1, live == 0`; stdout is `hello\n` (puts
/// adds the newline) — confirming the memcpy preserved every byte.
#[test]
fn str_clone_drop_balances_rc_stats() {
let (stdout, allocs, frees, live) =
build_and_run_with_rc_stats("str_clone_drop_rc.ail");
assert_eq!(stdout, "hello\n");
assert_eq!(allocs, 1, "expected exactly one heap-Str clone slab; got allocs={allocs}");
assert_eq!(frees, 1, "expected exactly one free; got frees={frees}");
assert_eq!(live, 0, "no leaked heap-Str slabs allowed; live={live}");
}
/// cross-realisation invariant — `str_clone` works
/// uniformly on heap-Str input (`int_to_str 42`'s output) AND on
/// static-Str input (literal `"abc"`). Both clones print their
/// input bytes; RC stats account for three heap-Str slabs (one
/// from int_to_str, two from str_clone) and three matching frees.
/// Pins the "uniform consumer ABI" claim from
/// design/contracts/str-abi.md — str_clone only reads len + bytes +
/// NUL, never the rc_header.
#[test]
fn str_clone_cross_realisation_uniform_abi() {
let (stdout, allocs, frees, live) =
build_and_run_with_rc_stats("str_clone_cross_realisation.ail");
assert_eq!(stdout, "42\nabc\n");
assert_eq!(allocs, 3, "expected one int_to_str slab + two str_clone slabs; got allocs={allocs}");
assert_eq!(frees, 3, "expected three matching frees; got frees={frees}");
assert_eq!(live, 0, "no leaked heap-Str slabs allowed; live={live}");
}
/// `examples/mut_counter.ail` sums 1..10 via a tail-recursive
/// `sum_helper` and prints the result. Behaviour-preservation gate:
/// the fixture's value assertion stays `55` after the let/if rewrite,
/// proving the rewrite loses no expressivity.
#[test]
fn mut_counter_prints_55() {
let stdout = build_and_run("mut_counter.ail");
assert_eq!(stdout.trim(), "55", "mut_counter must print 55, got {stdout:?}");
}
/// Float twin of `mut_counter_prints_55`: `sum_helper` returns the
/// sum 1.0+...+10.0 = 55.0. The polymorphic `print` routes through
/// `Show Float.show` → `float_to_str`'s libc `%g` formatter plus
/// the runtime `.0`-fallback for finite whole-valued doubles whose
/// `%g` output lacks `.`/`e`/`E` (Gitea #7), so the canonical
/// stdout for Float 55.0 is `"55.0"` (matches `examples/floats.ail`'s
/// output of `4.0` for `1.5 + 2.5`).
#[test]
fn mut_sum_floats_prints_55() {
let stdout = build_and_run("mut_sum_floats.ail");
assert_eq!(stdout.trim(), "55.0", "mut_sum_floats must print 55.0, got {stdout:?}");
}