Files
AILang/crates/ail/tests/e2e.rs
T
Brummel ca30606aec Iter 16b.3: post-typecheck lift for LetRec with Let-bound captures
Adds path-2 from the 16b.2 planning entry. Desugar now defers
LetRecs whose captures include Term::Let-bound names; a new
ailang-check::lift_letrecs pass runs after typecheck and uses the
elaborated env to resolve capture types. ailang-check learns a real
Term::LetRec typing rule in synth and verify_tail_positions.

- desugar: Term::LetRec arm gains a defer-arm; helpers promoted to
  pub for reuse by the lift pass; find_non_callee_use moved before
  classification.
- ailang-check::synth/verify_tail_positions: real LetRec rules
  (effect-subset, locals install, recursive name in body+in_term).
- ailang-check::lift.rs (new, 720 LOC): post-typecheck lift with
  post-order traversal, env-walk for capture-type resolution,
  fast-path skip when no LetRec is present.
- ail::main.rs: build path now does load → check → desugar →
  lift_letrecs → codegen.
- examples/local_rec_let_capture.{ailx,ail.json}: new fixture
  capturing a let-bound `threshold` in a recursive helper.
- e2e + check unit tests: 106 → 110 (+4).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 22:07:05 +02:00

1013 lines
39 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! End-to-end test: load example module, compile to binary, run it.
//!
//! This test guards the most important property of the entire pipeline:
//! AST → typecheck → LLVM IR → clang → binary → correct stdout.
use std::path::Path;
use std::process::Command;
fn ail_bin() -> &'static str {
env!("CARGO_BIN_EXE_ail")
}
fn build_and_run(example: &str) -> String {
// Workspace root is two levels above the crate manifest.
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
let src = workspace.join("examples").join(example);
let tmp = std::env::temp_dir().join(format!(
"ailang_e2e_{}_{}",
example.replace('.', "_"),
std::process::id()
));
std::fs::create_dir_all(&tmp).unwrap();
let out = tmp.join("bin");
let status = Command::new(ail_bin())
.args(["build", src.to_str().unwrap(), "-o"])
.arg(&out)
.status()
.expect("ail build failed to run");
assert!(status.success(), "ail build failed for {example}");
let output = Command::new(&out).output().expect("execute binary");
assert!(
output.status.success(),
"binary {} exited non-zero",
out.display()
);
String::from_utf8(output.stdout).expect("stdout utf8")
}
#[test]
fn sum_1_to_10_is_55() {
let stdout = build_and_run("sum.ail.json");
assert_eq!(stdout.trim(), "55");
}
/// 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.json");
assert_eq!(stdout.trim(), "17");
}
#[test]
fn hello_world_str_lit() {
let stdout = build_and_run("hello.ail.json");
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.json");
assert_eq!(stdout.trim(), "42");
}
/// Iter 7: 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.json");
assert_eq!(stdout.trim(), "42");
}
/// Iter 8b: 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.json");
assert_eq!(stdout.trim(), "42");
}
/// Iter 9 dogfood: 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.json");
let lines: Vec<&str> = stdout.lines().collect();
assert_eq!(lines, vec!["2", "4", "6"]);
}
/// Iter 14a: 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.json");
let lines: Vec<&str> = stdout.lines().collect();
assert_eq!(lines, vec!["2", "3", "4"]);
}
/// Iter 14f: stress the Boehm conservative GC integration end-to-end.
/// `build 50` performs 50 `Cons` allocations via `GC_malloc`; `sum_list`
/// walks the resulting `List Int` and prints the sum (1275 = 50*51/2).
/// If GC is wired wrongly — missing `-lgc`, missing
/// `declare ptr @GC_malloc(i64)`, libgc misbehaving on this build host —
/// the test fails at link or run time. The match-on-Bool against
/// `(== n 0)` shape is used because `(pat-lit 0)` against an `Int`
/// scrutinee with a wildcard fallback is not currently in the grammar.
#[test]
fn gc_handles_recursive_list_construction() {
let stdout = build_and_run("gc_stress.ail.json");
assert_eq!(
stdout.trim(),
"1275",
"expected sum [50,49,..,1] = 1275; \
GC_malloc / -lgc integration may be broken"
);
}
/// Iter 14e: `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.json");
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)");
}
/// Iter 11 dogfood: 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.json");
let lines: Vec<&str> = stdout.lines().collect();
assert_eq!(
lines,
vec!["1", "1", "2", "3", "3", "4", "5", "5", "5", "6", "9"],
);
}
/// Iter 12b: 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.json");
let lines: Vec<&str> = stdout.lines().collect();
assert_eq!(lines, vec!["42", "true"]);
}
/// Iter 12b: 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.json");
assert_eq!(stdout.trim(), "42");
}
/// Iter 13b: 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.json");
assert_eq!(stdout.trim(), "42");
}
/// Iter 13b: 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.json");
let lines: Vec<&str> = stdout.lines().collect();
assert_eq!(lines, vec!["7", "99"]);
}
/// Iter 15a: cross-module reference to a parameterised ADT, including
/// its ctors and a polymorphic combinator instantiated at `(Int, Int)`.
/// `std_maybe_demo` imports `std_maybe`, qualifies the type-name slot
/// of every `term-ctor` (`std_maybe.Maybe`), and exercises
/// `from_maybe`, `is_some`, `is_none`, and `map_maybe` once each.
/// Property protected: the qualified-only convention (Decision 6's
/// architectural pin extended to types and ctors per the brief)
/// flows end-to-end through check, codegen, and runtime.
#[test]
fn cross_module_maybe_demo() {
let stdout = build_and_run("std_maybe_demo.ail.json");
let lines: Vec<&str> = stdout.lines().collect();
assert_eq!(lines, vec!["7", "99", "true", "true", "42"]);
}
/// Iter 15b: drives the polymorphic `std_list` combinators end-to-end.
/// Exercises a recursive cross-module ADT (`std_list.List<a>`) consumed
/// from a separate module that also imports `std_maybe`. Guards the
/// `qualify_local_types` propagation in both `Term::Ctor` synth and
/// `Term::Match` field binding — without it, recursive ctor fields
/// (`Cons a (List a)`) stay unqualified at the cross-module use site
/// and fail to unify against the qualified scrutinee args.
#[test]
fn std_list_demo() {
let stdout = build_and_run("std_list_demo.ail.json");
let lines: Vec<&str> = stdout.lines().collect();
assert_eq!(
lines,
vec![
"5", "false", "true", "1", "4", "10", "5", "2", "2", "15", "15"
]
);
}
/// Iter 15c: 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.json");
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"
);
}
/// Iter 15e: `ail render | ail parse` round-trips canonical bytes.
/// Property protected: the CLI's `render` is the exact inverse of
/// `parse`, not the older human-pretty form. Regression of this
/// would mean piping `render` into `parse` no longer yields a
/// re-loadable module — a contract the help text now claims.
///
/// Picks `std_either.ail.json` (richest fixture: 2-type-var data,
/// 3-type-var fn, recursive ctors irrelevant) but the round-trip
/// test in `ailang-surface` covers all 25 fixtures structurally.
/// This test specifically exercises the CLI shell pipeline.
#[test]
fn render_parse_round_trip_canonical() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
let src = workspace.join("examples").join("std_either.ail.json");
let original = std::fs::read(&src).expect("read fixture");
let render = Command::new(ail_bin())
.args(["render", src.to_str().unwrap()])
.output()
.expect("run ail render");
assert!(render.status.success(), "ail render failed");
let tmp = std::env::temp_dir().join(format!("ailang_render_e2e_{}.ailx", std::process::id()));
std::fs::write(&tmp, &render.stdout).expect("write rendered ailx");
let parsed = Command::new(ail_bin())
.args(["parse", tmp.to_str().unwrap()])
.output()
.expect("run ail parse");
assert!(parsed.status.success(), "ail parse failed on rendered output");
let mut round = parsed.stdout;
while round.last() == Some(&b'\n') {
round.pop();
}
let mut orig = original;
while orig.last() == Some(&b'\n') {
orig.pop();
}
assert_eq!(orig, round, "render | parse must produce canonical bytes");
}
/// Iter 16a: 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.json");
let lines: Vec<&str> = stdout.lines().collect();
assert_eq!(lines, vec!["30"]);
}
/// Iter 15f: `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.json");
let lines: Vec<&str> = stdout.lines().collect();
assert_eq!(lines, vec!["7", "9", "9", "7", "8", "18"]);
}
/// Iter 15d: `std_either` end-to-end. First stdlib ADT with two type
/// parameters (`Either<e, a>`); first combinator with three type vars
/// (`either : (e -> c) -> (a -> c) -> Either<e, a> -> c`).
///
/// 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.json");
let lines: Vec<&str> = stdout.lines().collect();
assert_eq!(
lines,
vec!["42", "99", "true", "true", "42", "6", "101"]
);
}
/// Iter 15g: `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.json");
let lines: Vec<&str> = stdout.lines().collect();
assert_eq!(lines, vec!["2", "3", "2", "3"]);
}
/// Iter 15h: `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.json");
let lines: Vec<&str> = stdout.lines().collect();
assert_eq!(lines, vec!["0", "3", "5", "5", "3", "0"]);
}
/// Iter 16b.1: 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.json");
let lines: Vec<&str> = stdout.lines().collect();
assert_eq!(lines, vec!["1", "6", "120"]);
}
/// Iter 16b.2: 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.json");
let lines: Vec<&str> = stdout.lines().collect();
assert_eq!(lines, vec!["0", "10", "45"]);
}
/// Iter 16b.3: 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.json");
let lines: Vec<&str> = stdout.lines().collect();
assert_eq!(lines, vec!["0", "5", "9"]);
}
/// 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();
let src_a = workspace.join("examples").join("sum.ail.json");
// Variant: load sum.ail.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 sum.ail.json");
let mut module: serde_json::Value = serde_json::from_slice(&raw).expect("parse 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.json");
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.json");
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");
assert_eq!(modules.len(), 2, "expected 2 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:?}");
}
/// 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.json");
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.json");
assert_eq!(stdout.trim(), "5", "ws_lib.add(2,3) should print 5");
}
/// 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.json");
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.json");
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 `+`, `-`, `==`, the param `n`, and the recursive call `sum`.
/// Only the recursive call must remain.
#[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.json");
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!["sum"],
"expected only 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.json");
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.json");
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}"
);
}
/// Iter 16c: 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).
#[test]
fn lit_pat_demo() {
let stdout = build_and_run("lit_pat.ail.json");
let lines: Vec<&str> = stdout.lines().collect();
assert_eq!(lines, vec!["100", "200", "999", "-1", "0", "7"]);
}