35a9bc67b1
Series a is a bounded ring buffer with financial-style indexing (index 0 = newest), shipped as a PURE AILang library extension over RawBuf — zero Rust codegen, zero intercepts, zero (intrinsic) markers. It is the first kernel-tier module that depends on another kernel-tier module (raw_buf). This turns the committed SMA headline pin (6b7ee45) green and closes the step-1 scope of #61. The module lives at crates/ailang-kernel/src/series/{source.ail, mod.rs} and is auto-injected by the loader exactly like raw_buf (parse_series + injection in ailang-surface/src/loader.rs, re-export in lib.rs). Five real-bodied ops: new / push / at / len / total_count, all built on RawBuf.{new,set,get}. The source is the model-0007 sketch with every defect corrected and each correction verified against the live tool: - param-in narrowed to (a Int Float); Bool deferred per #61. - inner alloc is `(new RawBuf lookback)` with no element type-arg — the element is solved from the `(con RawBuf a)` ctor-field context, which the base-tier mono fix (b11a6d9) carries through to the caller's concrete type. This is the capability that lets Series be pure AILang. - element-typed params/rets are bare `a`, not `(con a)` (a type variable, not a nullary ctor). - op signatures bind `a` via `(forall (vars a) ...)`. - comparison uses the named helper `lt`, not the non-existent `<`. - the at-index formula is `(head - 1 - i + 2*lookback) mod lookback` (0007 had the sign on i wrong, walking the wrong direction). Cross-kernel-module visibility — the flagged risk — needed ZERO mechanism change: the loader derives its implicit-import set from every kernel module, so series's body resolves `RawBuf.*` and `(con RawBuf a)` via the existing kernel auto-visibility. Both CLAUDE.md lockstep pairs stay intact: - INTERCEPTS <-> (intrinsic) markers: unchanged. Series is pure-AILang, adds neither; intercepts_bijection_with_intrinsic_ markers still green. - Term::New typecheck/reject <-> pre_desugar_validation.rs: untouched; the new/Series construction exercises existing paths. Test-surface ripple, all verified: - workspace module-count pins 4 -> 5 (e2e.rs, workspace_pin.rs), with a `series` membership assertion added — the kernel set grew by one deliberate module. - two mono-identity tests (typeclass_22b3.rs) now skip `prelude` and `series`: series is the first auto-imported real-bodied consumer of the prelude polyfns `lt`/`compare` over Int, so mono legitimately materialises `lt__Int`/`compare__Int` into prelude for every workspace. That is a concrete call site, not gratuitous mutation; the tests' real invariant (mono leaves work-free modules untouched) still holds for every other module. - five IR snapshots refreshed; the deltas are strictly additive (zero IR deletions) — the same series-driven Int specialisations. Verified: full `cargo test --workspace` green; SMA binary built via `ail build --alloc=rc` prints `3.0 / 5.33333 / 5.66667 / 5.33333`. refs #61
3115 lines
132 KiB
Rust
3115 lines
132 KiB
Rust
//! 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 ailang_test_support::workspace_root;
|
||
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");
|
||
}
|
||
|
||
/// Property: a static `Str` literal passed by value into an `(own Str)`
|
||
/// parameter that the callee drops must be RC-sound — dropped exactly
|
||
/// once, no double-free, no leak.
|
||
///
|
||
/// RED for the #55-cutover drop-soundness crash. The literal lowers to
|
||
/// `StrRep::Static` (a header-less rodata constant), but the cutover
|
||
/// activates the Own-param drop path, which emits `ailang_rc_dec` on
|
||
/// the unused param. `rc_dec` reads `payload - 8` as a refcount and
|
||
/// `free()`s a static address -> SIGSEGV. `lower_to_mir`'s `Term::App`
|
||
/// arm must flip such an argument to `StrRep::Heap` (str_clone into an
|
||
/// owned slab), the same promotion mir.4 applies to loop-carried Str
|
||
/// literals. Distinct root from legs A/B (the lit-pattern desugar); the
|
||
/// match in eq_demo merely happens to drop its owned Str scrutinee too.
|
||
///
|
||
/// The assertion is robust against the stats-path masking leg A showed:
|
||
/// it runs the binary on the NON-stats path (catching the SIGSEGV via
|
||
/// the exit status) AND on the stats path asserting `live=0` /
|
||
/// `frees==allocs`.
|
||
#[test]
|
||
fn own_str_literal_arg_is_dropped_exactly_once() {
|
||
let manifest_dir = env!("CARGO_MANIFEST_DIR");
|
||
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
|
||
let src = workspace.join("examples").join("own_str_arg_drop.ail");
|
||
let tmp = std::env::temp_dir().join(format!(
|
||
"ailang_e2e_own_str_arg_drop_{}",
|
||
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 own_str_arg_drop.ail");
|
||
|
||
// NON-stats path: this is the crashing path (SIGSEGV / double-free).
|
||
let plain = Command::new(&out).output().expect("execute binary");
|
||
assert!(
|
||
plain.status.success(),
|
||
"binary {} exited non-zero (expected success; got {:?}) — \
|
||
the owned Str literal was dropped on a header-less static",
|
||
out.display(),
|
||
plain.status.code()
|
||
);
|
||
assert_eq!(
|
||
String::from_utf8(plain.stdout).expect("stdout utf8").trim(),
|
||
"0"
|
||
);
|
||
|
||
// Stats path: RC balance must close — no leaked or double-freed Str.
|
||
let stats = Command::new(&out)
|
||
.env("AILANG_RC_STATS", "1")
|
||
.output()
|
||
.expect("execute binary with RC stats");
|
||
assert!(
|
||
stats.status.success(),
|
||
"binary {} (RC stats) exited non-zero (expected success; got {:?})",
|
||
out.display(),
|
||
stats.status.code()
|
||
);
|
||
let stderr = String::from_utf8(stats.stderr).expect("stderr utf8");
|
||
let line = stderr
|
||
.lines()
|
||
.find(|l| l.starts_with("ailang_rc_stats:"))
|
||
.unwrap_or_else(|| panic!("no ailang_rc_stats line in stderr: {stderr:?}"));
|
||
// Format: `ailang_rc_stats: allocs=N frees=M live=K`
|
||
assert!(
|
||
line.contains("live=0"),
|
||
"RC balance not closed: {line:?} (live != 0 means leak or double-free)"
|
||
);
|
||
}
|
||
|
||
#[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)"
|
||
);
|
||
}
|
||
|
||
/// RED for fieldtest finding B2 (raw-buf milestone, Gitea #7):
|
||
/// a `loop` whose result is `let`-bound and whose body references one
|
||
/// of its own binders on the non-recur exit must BUILD, not only
|
||
/// `check`. The `Term::Let` codegen lowering replays the value's
|
||
/// AILang type via `synth_arg_type`; that replay walk
|
||
/// (`synth_with_extras`) must descend through `Term::Loop` with the
|
||
/// loop binders in scope — as it already does for `Term::Let` — or the
|
||
/// binder reference resolves to `UnknownVar`, breaking check↔codegen
|
||
/// agreement. Element-type-independent: the fieldtest hit it with an
|
||
/// owned `RawBuf` binder; a plain `Int` binder is the minimal trigger.
|
||
#[test]
|
||
fn loop_let_bound_binder_reference_builds() {
|
||
let stdout = build_and_run("loop_let_bound_binder_ref.ail");
|
||
assert_eq!(stdout.trim(), "3");
|
||
}
|
||
|
||
/// 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` and (prep.1 migration) uses
|
||
/// the type-scoped form: bare `Maybe` at every `term-ctor` type-name
|
||
/// slot, `Maybe.from_maybe` / `Maybe.is_some` etc. for the
|
||
/// combinators. Exercises four combinators once each. Property
|
||
/// protected: prep.1's type-scoped namespacing 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 (`List<a>` from `std_list`,
|
||
/// prep.1 type-scoped form) consumed from a separate module that
|
||
/// also imports `std_maybe`. Guards the `qualify_local_types` /
|
||
/// `qualify_workspace_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 every built-in kernel-tier module
|
||
// (`prelude`; `raw_buf` since raw-buf.4; and `series`, the first pure
|
||
// AILang library extension, since feat-series-step1), so the count is
|
||
// the user's two modules plus the three built-ins.
|
||
assert_eq!(modules.len(), 5, "expected 5 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:?}");
|
||
assert!(names.contains(&"raw_buf"), "raw_buf missing: {names:?}");
|
||
assert!(names.contains(&"series"), "series 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}"
|
||
);
|
||
}
|
||
|
||
/// Property protected: an `(intrinsic)` body is rejected in a
|
||
/// non-kernel-tier user module. `check_fn`'s honesty-rule guard fires
|
||
/// `intrinsic-outside-kernel-tier` (Error) when a module that is
|
||
/// neither kernel-tier nor the prelude declares a compiler-supplied
|
||
/// body. Without the guard, user code could claim the compiler
|
||
/// supplies a body it does not. The reject rides the subprocess
|
||
/// `ail check --json` path so the diagnostic code is asserted as the
|
||
/// machine consumer would see it; a temp file keeps the reject case
|
||
/// out of the round-trip corpus.
|
||
#[test]
|
||
fn intrinsic_in_user_module_is_rejected() {
|
||
let tmp = std::env::temp_dir().join(format!(
|
||
"ailang_intrinsic_reject_{}",
|
||
std::process::id()
|
||
));
|
||
std::fs::create_dir_all(&tmp).unwrap();
|
||
let src = tmp.join("intr_user.ail");
|
||
std::fs::write(
|
||
&src,
|
||
"(module intr_user (fn f (type (fn-type (params (own (con Int))) (ret (own (con Int))))) (params n) (intrinsic)))",
|
||
)
|
||
.unwrap();
|
||
|
||
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 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("intrinsic-outside-kernel-tier")
|
||
}),
|
||
"expected an error with code intrinsic-outside-kernel-tier; 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"]);
|
||
}
|
||
|
||
/// RED (refs #55, Implicit-cutover double-drop). Property protected:
|
||
/// a ctor arm with a literal sub-pattern in a non-tail field —
|
||
/// `(Cons (pat-lit K) _)` — drops the matched value's owned children
|
||
/// EXACTLY ONCE, on both the head==K and head!=K paths.
|
||
///
|
||
/// The 16c lit-sub-pattern desugar lowers the arm into an outer
|
||
/// Cons-match (binding the fields) wrapping `if (== head K) body
|
||
/// else fall_k`; `fall_k` re-matches the same owned scrutinee, re-
|
||
/// binding its children. Post-#55 the param mode is `Own`, so the
|
||
/// match_lower.rs arm-close Iter-A drop (gated on `scrutinee_is_owned`)
|
||
/// fires on the tail child in BOTH the re-match arm AND the enclosing
|
||
/// lit-arm's join — `arg_xs+16` is dropped twice → double-free →
|
||
/// SIGSEGV on the head!=K path.
|
||
///
|
||
/// Two assertions, so the symptom is pinned independently of the
|
||
/// stats-drop masking divergence:
|
||
/// 1. `build_and_run` (no AILANG_RC_STATS): asserts the binary exits
|
||
/// 0 AND prints `0` then `7` — surfaces the segfault directly.
|
||
/// 2. rc-stats: `live == 0` and `frees == allocs` — pins single-drop
|
||
/// (a double-free would over-count frees / corrupt the heap)
|
||
/// rather than mere exit-0.
|
||
#[test]
|
||
fn lit_pat_ctor_tail_drop_single_drop() {
|
||
let stdout = build_and_run("lit_pat_ctor_tail_drop.ail");
|
||
let lines: Vec<&str> = stdout.lines().collect();
|
||
assert_eq!(lines, vec!["0", "7"]);
|
||
|
||
let (stats_stdout, allocs, frees, live) =
|
||
build_and_run_with_rc_stats("lit_pat_ctor_tail_drop.ail");
|
||
assert_eq!(
|
||
stats_stdout.lines().collect::<Vec<_>>(),
|
||
vec!["0", "7"],
|
||
"stdout under rc-stats must match the non-stats run"
|
||
);
|
||
assert_eq!(live, 0, "owned IntList children must all be freed (no leak)");
|
||
assert_eq!(
|
||
frees, allocs,
|
||
"every alloc freed exactly once — a double-free over-counts frees"
|
||
);
|
||
}
|
||
|
||
/// RED (refs #55, Implicit-cutover husk leak — leg B, Nil sub-case).
|
||
/// Property protected: an owned scrutinee threaded through the
|
||
/// lit-sub-pattern chain desugar has its OUTER cell freed exactly once
|
||
/// on the Nil return path — the path that matches the Nil arm and
|
||
/// touches neither inner Cons re-match.
|
||
///
|
||
/// The 16c desugar takes `first_or_default` off the `is_flat`
|
||
/// fast-path (it has a lit sub-pattern arm), let-binds the owned
|
||
/// scrutinee into a fresh `$mp` var, and chains single-level matches.
|
||
/// Post-#55 the `Let $mp = xs` consumes `xs` (consume_count := 1),
|
||
/// which trips the codegen fn-return gate (lib.rs ~1522, `if
|
||
/// consume_count != 0 { continue }`) and suppresses the husk free a
|
||
/// single-match owned fn would emit as `partial_drop_<T>(arg_xs, _)`.
|
||
/// The husk obligation also moves to the internal `$mp` binder the
|
||
/// gate never inspects. The Nil cell leaks -> live=1.
|
||
///
|
||
/// Distinct from `lit_pat_ctor_tail_drop_single_drop`, which only
|
||
/// drives the Cons path: a fix that frees the husk on the Cons path
|
||
/// but forgets the Nil path passes that test while still leaking here.
|
||
#[test]
|
||
fn lit_pat_nil_scrutinee_single_drop() {
|
||
let stdout = build_and_run("lit_pat_nil_scrutinee_drop.ail");
|
||
assert_eq!(stdout.lines().collect::<Vec<_>>(), vec!["-1"]);
|
||
|
||
let (stats_stdout, allocs, frees, live) =
|
||
build_and_run_with_rc_stats("lit_pat_nil_scrutinee_drop.ail");
|
||
assert_eq!(
|
||
stats_stdout.lines().collect::<Vec<_>>(),
|
||
vec!["-1"],
|
||
"stdout under rc-stats must match the non-stats run"
|
||
);
|
||
assert_eq!(
|
||
live, 0,
|
||
"the Nil scrutinee's outer cell must be freed (no husk leak)"
|
||
);
|
||
assert_eq!(
|
||
frees, allocs,
|
||
"every alloc freed exactly once — a double-free over-counts frees"
|
||
);
|
||
}
|
||
|
||
/// `__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");
|
||
// mir.1b: the desugar→lift→mono→lower bridge collapses into the
|
||
// single `elaborate_workspace` front-end, which produces the typed
|
||
// MIR codegen now consumes.
|
||
let mir = ailang_check::elaborate_workspace(&ws).expect("elaborates");
|
||
let ir = ailang_codegen::lower_workspace_with_alloc(
|
||
&mir,
|
||
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");
|
||
// mir.1b: the desugar→lift→mono→lower bridge collapses into the
|
||
// single `elaborate_workspace` front-end, which produces the typed
|
||
// MIR codegen now consumes.
|
||
let mir = ailang_check::elaborate_workspace(&ws).expect("elaborates");
|
||
let ir = ailang_codegen::lower_workspace_with_alloc(
|
||
&mir,
|
||
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");
|
||
// mir.1b: the desugar→lift→mono→lower bridge collapses into the
|
||
// single `elaborate_workspace` front-end, which produces the typed
|
||
// MIR codegen now consumes.
|
||
let mir = ailang_check::elaborate_workspace(&ws).expect("elaborates");
|
||
let ir = ailang_codegen::lower_workspace_with_alloc(
|
||
&mir,
|
||
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");
|
||
// mir.1b: the desugar→lift→mono→lower bridge collapses into the
|
||
// single `elaborate_workspace` front-end, which produces the typed
|
||
// MIR codegen now consumes.
|
||
let mir = ailang_check::elaborate_workspace(&ws).expect("elaborates");
|
||
let ir = ailang_codegen::lower_workspace_with_alloc(
|
||
&mir,
|
||
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");
|
||
// mir.1b: the desugar→lift→mono→lower bridge collapses into the
|
||
// single `elaborate_workspace` front-end, which produces the typed
|
||
// MIR codegen now consumes.
|
||
let mir = ailang_check::elaborate_workspace(&ws).expect("elaborates");
|
||
let ir = ailang_codegen::lower_workspace_with_alloc(
|
||
&mir,
|
||
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,
|
||
// then elaborate the mutated source workspace to MIR (mir.1b: the
|
||
// single front-end replaces the explicit desugar→lift→mono bridge).
|
||
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;
|
||
}
|
||
}
|
||
modules.insert(mname.clone(), mc);
|
||
}
|
||
let mutated_ws = ailang_core::Workspace {
|
||
entry: ws.entry.clone(),
|
||
modules,
|
||
root_dir: ws.root_dir.clone(),
|
||
registry: ws.registry.clone(),
|
||
};
|
||
let mir = ailang_check::elaborate_workspace(&mutated_ws).expect("elaborates");
|
||
let ir = ailang_codegen::lower_workspace_with_alloc(
|
||
&mir,
|
||
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"),
|
||
)
|
||
}
|
||
|
||
/// raw-buf.4 worked consumer: a 3-slot Int RawBuf filled 10/20/30,
|
||
/// summed and printed. The end-to-end acceptance criterion — parse →
|
||
/// check → desugar `(new RawBuf …)` → mono `RawBuf_{new,set,get}__Int`
|
||
/// → intercept-routed codegen (alloc/store/load over the slab) →
|
||
/// native `60`.
|
||
#[test]
|
||
fn raw_buf_int_e2e() {
|
||
let out = build_and_run("raw_buf_int.ail");
|
||
assert_eq!(out.trim(), "60");
|
||
}
|
||
|
||
/// Property (refs #42): an owned RawBuf that is no longer referenced
|
||
/// at the end of `main` must be dropped — every slab alloc is matched
|
||
/// by a free, so `live == 0` at exit. The flat drop function
|
||
/// `@drop_raw_buf_RawBuf` is emitted (raw-buf.4), but no call to it is
|
||
/// emitted in `main`'s body, so the slab leaks (`allocs=2 frees=1`,
|
||
/// the lone free being the `int_to_str` string from `print`). RED
|
||
/// until the drop call fires.
|
||
///
|
||
/// NOTE: the leak is independent of binder names. The fully inline
|
||
/// form — `(app RawBuf.get (app RawBuf.set (new RawBuf (con Int) 1)
|
||
/// 0 10) 0)`, no `let` at all — produces byte-identical leaking IR,
|
||
/// as does a distinct-name (`b0`/`b1`) variant. This contradicts the
|
||
/// root-cause stated in issue #42 (uniqueness-table binder-name
|
||
/// collapse under shadow-rebind): with no binder there is nothing to
|
||
/// collapse, yet the drop call is still absent. The defect is in the
|
||
/// owned-RawBuf drop-emission decision, not in name-keyed ownership.
|
||
#[test]
|
||
fn raw_buf_owned_drop_balances_rc_stats() {
|
||
let (stdout, allocs, frees, live) =
|
||
build_and_run_with_rc_stats("raw_buf_drop_min.ail");
|
||
assert_eq!(stdout.trim(), "10", "fixture must still read back the stored value");
|
||
assert_eq!(
|
||
live, 0,
|
||
"owned RawBuf leaks {live} slab(s) (allocs={allocs} frees={frees}); \
|
||
the drop call for the owned buffer must fire at end of main"
|
||
);
|
||
}
|
||
|
||
/// Property (A2, refs #42 / #43): the shadow-rebind RawBuf idiom —
|
||
/// `(let buf (new …) (let buf (set buf …) … (get buf)))` — must drop
|
||
/// its owned slab at scope close. This is the LLM-natural shipped
|
||
/// form (`examples/raw_buf_int.ail`, the actual #42 symptom), not a
|
||
/// reduced repro: a 3-slot Int buffer filled then summed via three
|
||
/// borrowing `get`s. The final `buf` binder is dead after the last
|
||
/// `get` and must be dec'd once.
|
||
///
|
||
/// This is a SEPARATE defect from the A1 anonymous-temp leak fixed in
|
||
/// 62cd4b4. Here the value reaching `get` is a bound `Term::Var`, not
|
||
/// an anonymous call temp, so A1's call-site rule correctly does not
|
||
/// fire. The leak is in uniqueness inference: `RawBuf.get`/`.size`
|
||
/// take their RawBuf param `borrow`, but their post-mono callee Var
|
||
/// (`raw_buf.RawBuf_get__Int`) is absent from the uniqueness pass's
|
||
/// `globals` map (registered only under the bare local name
|
||
/// `RawBuf_get__Int`), so `callee_arg_modes` misses, every arg
|
||
/// defaults to `Position::Consume`, and the borrowed `buf` is counted
|
||
/// as a consume. That inflates the binder's `consume_count` to 1,
|
||
/// suppressing the `Term::Let` scope-close drop gate
|
||
/// (`crates/ailang-codegen/src/lib.rs:1795`, `consume_count == 0`).
|
||
///
|
||
/// NOT issue #42's "shadow-name collapse": #42's own evidence shows
|
||
/// the distinct-name variant (`b0`/`b1`/…) leaks identically, so the
|
||
/// defect is the borrow-counted-as-consume default, not name-keyed
|
||
/// ownership. RED until the borrow modes of the intrinsic ops are
|
||
/// visible to the consume-counter.
|
||
#[test]
|
||
fn raw_buf_int_shadow_rebind_drop_balances_rc_stats() {
|
||
let (stdout, allocs, frees, live) =
|
||
build_and_run_with_rc_stats("raw_buf_int.ail");
|
||
assert_eq!(stdout.trim(), "60", "fixture must still sum to 60");
|
||
assert_eq!(
|
||
live, 0,
|
||
"shadow-rebind RawBuf leaks {live} slab(s) (allocs={allocs} frees={frees}); \
|
||
the final `buf` binder must be dropped at end of main"
|
||
);
|
||
}
|
||
|
||
/// A2 Float sibling: the shadow-rebind drop must balance for the
|
||
/// `double`-element shipped fixture too (`examples/raw_buf_float.ail`,
|
||
/// prints `4.0`). Same mechanism and RED state as the Int case.
|
||
#[test]
|
||
fn raw_buf_float_shadow_rebind_drop_balances_rc_stats() {
|
||
let (stdout, allocs, frees, live) =
|
||
build_and_run_with_rc_stats("raw_buf_float.ail");
|
||
assert_eq!(stdout.trim(), "4.0", "fixture must still print 4.0");
|
||
assert_eq!(
|
||
live, 0,
|
||
"shadow-rebind RawBuf leaks {live} slab(s) (allocs={allocs} frees={frees}); \
|
||
the final `buf` binder must be dropped at end of main"
|
||
);
|
||
}
|
||
|
||
/// A2 Bool sibling: the shadow-rebind drop must balance for the
|
||
/// `i1`-element shipped fixture too (`examples/raw_buf_bool.ail`,
|
||
/// prints `42`). Same mechanism and RED state as the Int case.
|
||
#[test]
|
||
fn raw_buf_bool_shadow_rebind_drop_balances_rc_stats() {
|
||
let (stdout, allocs, frees, live) =
|
||
build_and_run_with_rc_stats("raw_buf_bool.ail");
|
||
assert_eq!(stdout.trim(), "42", "fixture must still print 42");
|
||
assert_eq!(
|
||
live, 0,
|
||
"shadow-rebind RawBuf leaks {live} slab(s) (allocs={allocs} frees={frees}); \
|
||
the final `buf` binder must be dropped at end of main"
|
||
);
|
||
}
|
||
|
||
/// A2b completeness (refs #43): the pattern-binder sibling of the
|
||
/// Let-shadow leak. A flat-match pattern-binder that shadows an outer
|
||
/// same-named heap binder must not leak more than its alpha-renamed
|
||
/// equivalent. The uniqueness side-table keys by `(def, binder_name)`
|
||
/// (`crates/ailang-check/src/uniqueness.rs`); the shadowing pattern
|
||
/// binder `x` collides with the outer `let x`, and the arm-close drop
|
||
/// gate (`crates/ailang-codegen/src/match_lower.rs:795`) reads the
|
||
/// collapsed (outer) `consume_count` and suppresses the inner
|
||
/// payload's drop. `flat_pat_shadow_leak.ail` (inner binder `x`,
|
||
/// shadows) and `flat_pat_shadow_control.ail` (inner binder `y`,
|
||
/// alpha-renamed) are byte-identical apart from that one name; the
|
||
/// shadow must reach the control's live count. RED until binder names
|
||
/// are made unique per fn at desugar (the same fix that closes the
|
||
/// three `raw_buf_*_shadow_rebind` Let-shadow tests). Differential
|
||
/// rather than `live == 0` on purpose: it isolates the shadow-collapse
|
||
/// drop from unrelated baseline drop gaps (Box outer cell,
|
||
/// implicit-consume of the outer binder) that are out of #43's scope.
|
||
#[test]
|
||
fn flat_pat_shadow_binder_does_not_leak_more_than_alpha_renamed() {
|
||
let (s_out, s_allocs, s_frees, s_live) =
|
||
build_and_run_with_rc_stats("flat_pat_shadow_leak.ail");
|
||
let (c_out, _c_allocs, _c_frees, c_live) =
|
||
build_and_run_with_rc_stats("flat_pat_shadow_control.ail");
|
||
assert_eq!(
|
||
s_out.trim(),
|
||
c_out.trim(),
|
||
"shadow and alpha-renamed control must print identically"
|
||
);
|
||
assert_eq!(
|
||
s_live, c_live,
|
||
"flat-pattern shadow leaks live={s_live} (allocs={s_allocs} frees={s_frees}) \
|
||
vs alpha-renamed control live={c_live}; the shadowing pattern-binder's \
|
||
arm-close drop is suppressed by the (def,name) uniqueness-table collision"
|
||
);
|
||
}
|
||
|
||
/// raw-buf.4 Float variant: a 2-slot Float RawBuf (1.5 + 2.5),
|
||
/// exercising the `double` load/store emits. Prints `4.0` (the `%g`
|
||
/// whole-double `.0` fallback).
|
||
#[test]
|
||
fn raw_buf_float_e2e() {
|
||
let out = build_and_run("raw_buf_float.ail");
|
||
assert_eq!(out.trim(), "4.0");
|
||
}
|
||
|
||
/// raw-buf.4 Bool variant: a 1-slot Bool RawBuf storing `true`, read
|
||
/// back and discriminated to an Int. Catches the `i1` store/load
|
||
/// syntax and the width-1 element offset.
|
||
#[test]
|
||
fn raw_buf_bool_e2e() {
|
||
let out = build_and_run("raw_buf_bool.ail");
|
||
assert_eq!(out.trim(), "42");
|
||
}
|
||
|
||
/// raw-buf.4 must-fail: `(con RawBuf (con Str))` violates the
|
||
/// `param-in (a Int Float Bool)` restriction. `ail check` exits
|
||
/// non-zero with the prep.3 `param-not-in-restricted-set` diagnostic.
|
||
#[test]
|
||
fn raw_buf_param_in_reject_e2e() {
|
||
let manifest_dir = env!("CARGO_MANIFEST_DIR");
|
||
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
|
||
let src = workspace.join("examples").join("raw_buf_reject_str.ail");
|
||
let output = Command::new(ail_bin())
|
||
.args(["check", src.to_str().unwrap()])
|
||
.output()
|
||
.expect("ail check failed to run");
|
||
assert!(
|
||
!output.status.success(),
|
||
"ail check must reject RawBuf<Str>"
|
||
);
|
||
let stderr = String::from_utf8(output.stderr).expect("stderr utf8");
|
||
assert!(
|
||
stderr.contains("param-not-in-restricted-set"),
|
||
"expected param-not-in-restricted-set, stderr: {stderr}"
|
||
);
|
||
}
|
||
|
||
|
||
/// 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 `(own T)`-ret-mode
|
||
/// call IS trackable for scope-close drop. ParamMode is binary
|
||
/// `{Own, Borrow}`: an unannotated return defaults to `Own`, the
|
||
/// callee's `(own T)` return signs the static contract that the
|
||
/// caller now owns the returned cell, and the let-scope close emits
|
||
/// the caller dec so the cell frees exactly once.
|
||
///
|
||
/// 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 = 0` — the one MkT cell frees. The own-return
|
||
/// contract flows ownership to the caller, which dec's the
|
||
/// binder at scope close. This is the typed-MIR leak fix spec
|
||
/// 0062 delivers (acceptance criterion 5): the legacy `Implicit`
|
||
/// back-compat lane that left this shape leaked (`live = 1`) is
|
||
/// gone, so the owned-ret binder is dropped exactly once.
|
||
///
|
||
/// This test is the positive companion to
|
||
/// `alloc_rc_let_binder_for_owned_returning_app_drops_at_scope_close`:
|
||
/// both `(own)`-ret-mode shapes free at scope close (`live = 0`).
|
||
#[test]
|
||
fn alloc_rc_let_binder_for_owned_returning_app_drops_exactly_once() {
|
||
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, 0,
|
||
"owned-ret-mode App binder must drop exactly once at scope \
|
||
close (post-cutover: no Implicit leak lane); allocs={allocs} \
|
||
frees={frees} live={live}. A live count of 1 would mean \
|
||
is_rc_heap_allocated failed to mark the owned-ret App as \
|
||
trackable; a non-zero exit means an 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 ----------
|
||
|
||
/// 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_builtin arm + IR-header declare + `Callee::Builtin`
|
||
/// classification in lower_to_mir),
|
||
/// 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");
|
||
}
|
||
|
||
/// mir.2 end-to-end: a program whose `main` exercises all three
|
||
/// resolved-callee kinds compiles and prints the correct result
|
||
/// through codegen's post-mir.2 `MTerm::App` consumer. `bump`'s body
|
||
/// `(app + n 1)` is a `Callee::Builtin` (inline opcode), `(app bump
|
||
/// 41)` is a same-module `Callee::Static`, and `(app int_to_str r)`
|
||
/// is a cross-module (prelude-fallback) `Callee::Static`. The
|
||
/// property protected: codegen reads the callee identity off MIR and
|
||
/// routes each kind to the right lowering — builtins inline, static
|
||
/// fns to `emit_call` with the module pre-resolved, dynamic callees
|
||
/// to the fn-pointer path — deriving nothing about the callee itself.
|
||
/// A regression that mis-resolves any kind surfaces as a wrong result
|
||
/// or a build failure here. `bump(41) = 42`, printed with no newline.
|
||
#[test]
|
||
fn mir2_resolved_callee_kinds_run_end_to_end() {
|
||
let out = build_and_run("mir2_callee_kinds.ail");
|
||
assert_eq!(out, "42");
|
||
}
|
||
|
||
/// `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/0011-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:?}");
|
||
}
|