7ae92d3e60
closes #60 The fixture-corpus filter (NON_PARSEABLE_FIXTURES + list_ail_fixtures) was copy-pasted across three test crates and drifted out of sync as new reject fixtures landed; the examples_dir / workspace_root path walk was reimplemented ~30 more times across the suite, under two spellings (workspace_root / ws_root). Introduce crates/ailang-test-support — a zero-dependency, dev-only leaf crate — as the single home for these helpers: - NON_PARSEABLE_FIXTURES, list_ail_fixtures - workspace_root, examples_dir - canonical_workspace_root (symlink-resolved variant, for the tsan/ race tests that feed the root into native build steps) All integration-test copies across ailang-core, ailang-surface, ailang-prose, ailang-check, and ail now import from the shared crate; the local definitions and their now-orphaned path imports are removed. Path helpers resolve via the support crate's own CARGO_MANIFEST_DIR, so they return the correct absolute paths from any caller. ail_bin helpers are intentionally left in place: they depend on env!("CARGO_BIN_EXE_ail"), which Cargo only defines when compiling the ail crate's own integration tests, so they cannot move to a shared crate. Behaviour-identical: same paths, same fixture lists; full workspace test suite green. Net -177 LOC.
224 lines
9.9 KiB
Rust
224 lines
9.9 KiB
Rust
//! Roundtrip Invariant gate (in-process) for the form-(A) projection.
|
|
//!
|
|
//! Two complementary checks over `examples/*.ail`, each gathering
|
|
//! fixtures dynamically via `read_dir` (no hardcoded lists). The
|
|
//! tests are pure readers — they do not write into the working
|
|
//! tree.
|
|
//!
|
|
//! 1. `parse_is_deterministic_over_every_ail_fixture` — pins
|
|
//! **parse-determinism** (property 1 of the post-form-a Roundtrip
|
|
//! Invariant, design/contracts/0009-roundtrip-invariant.md). For every
|
|
//! well-formed `.ail` text `t`, `parse(t)` produces the same
|
|
//! canonical bytes every invocation. The parser is a pure
|
|
//! function of input — no randomness, no time dependence, no
|
|
//! environment leak. Hashing `canonical::to_bytes(parse(t))` is
|
|
//! therefore well-defined for any `.ail` source.
|
|
//!
|
|
//! 2. `parse_then_print_then_parse_is_idempotent_on_every_ail_fixture`
|
|
//! — pins **idempotency-under-print** (property 2). For every
|
|
//! well-formed `.ail` text `t`, asserts
|
|
//! `canonical_bytes(parse(t))` equals
|
|
//! `canonical_bytes(parse(print(parse(t))))`. The printer is a
|
|
//! left-inverse of the parser modulo canonical form.
|
|
//!
|
|
//! The other two properties of the post-form-a Roundtrip Invariant
|
|
//! live in sibling test crates: **CLI-pipeline-idempotency** is
|
|
//! pinned by `crates/ail/tests/roundtrip_cli.rs::cli_parse_then_render_then_parse_is_idempotent`,
|
|
//! and **carve-out-anchor** is pinned by
|
|
//! `crates/ailang-core/tests/carve_out_inventory.rs::examples_ail_json_inventory_matches_carve_outs`.
|
|
//!
|
|
//! Retired iter form-a.1 T9: `print_then_parse_round_trips_every_fixture`
|
|
//! (corpus shrunk to 8 carve-outs post-iter) and
|
|
//! `every_ail_fixture_matches_its_json_counterpart` (no longer
|
|
//! expressible: only one form is hand-authored post-iter).
|
|
|
|
use ailang_test_support::{examples_dir, list_ail_fixtures};
|
|
|
|
// Retired iter form-a.1 T9:
|
|
// - `list_json_fixtures` helper (walked `.ail.json` for the two retired tests below).
|
|
// - `print_then_parse_round_trips_every_fixture`: corpus shrunk to 8 carve-outs
|
|
// post-iter; property subsumed by `parse_is_deterministic_over_every_ail_fixture`
|
|
// plus the surviving `parse_then_print_then_parse_is_idempotent_on_every_ail_fixture`.
|
|
// - `round_trip_one` helper (only consumer was the retired test above).
|
|
// - `every_ail_fixture_matches_its_json_counterpart`: no longer expressible —
|
|
// only one form is hand-authored post-iter; counterparts are derived in-process
|
|
// via `ail parse`.
|
|
|
|
/// Pins **idempotency-under-print** (property 2 of the post-form-a
|
|
/// Roundtrip Invariant, design/contracts/0009-roundtrip-invariant.md): for every
|
|
/// well-formed `.ail` text `t`, the composition `parse → print → parse`
|
|
/// is idempotent on the AST.
|
|
///
|
|
/// Direct enforcement complements the parse-determinism gate above
|
|
/// (which alone does not constrain the printer). Together they pin
|
|
/// `parse → print` as a left-inverse modulo canonical form for every
|
|
/// `.ail` fixture in the corpus.
|
|
#[test]
|
|
fn parse_then_print_then_parse_is_idempotent_on_every_ail_fixture() {
|
|
let fixtures = list_ail_fixtures();
|
|
assert!(
|
|
!fixtures.is_empty(),
|
|
"no .ail fixtures found under {}",
|
|
examples_dir().display()
|
|
);
|
|
|
|
let mut failures = Vec::<String>::new();
|
|
let mut passed = 0usize;
|
|
for path in &fixtures {
|
|
let text = match std::fs::read_to_string(path) {
|
|
Ok(t) => t,
|
|
Err(e) => {
|
|
failures.push(format!("{}: read failed: {e}", path.display()));
|
|
continue;
|
|
}
|
|
};
|
|
let parsed_once = match ailang_surface::parse(&text) {
|
|
Ok(m) => m,
|
|
Err(e) => {
|
|
failures.push(format!("{}: parse(t) failed: {e}", path.display()));
|
|
continue;
|
|
}
|
|
};
|
|
let printed = ailang_surface::print(&parsed_once);
|
|
let parsed_twice = match ailang_surface::parse(&printed) {
|
|
Ok(m) => m,
|
|
Err(e) => {
|
|
failures.push(format!(
|
|
"{}: parse(print(parse(t))) failed: {e}\n--- printed ---\n{printed}",
|
|
path.display()
|
|
));
|
|
continue;
|
|
}
|
|
};
|
|
let bytes_a = ailang_core::canonical::to_bytes(&parsed_once);
|
|
let bytes_b = ailang_core::canonical::to_bytes(&parsed_twice);
|
|
if bytes_a != bytes_b {
|
|
let s_a = String::from_utf8_lossy(&bytes_a).into_owned();
|
|
let s_b = String::from_utf8_lossy(&bytes_b).into_owned();
|
|
failures.push(format!(
|
|
"{}: parse → print → parse is NOT idempotent.\nparse(t): {s_a}\nparse(print(parse(t))): {s_b}",
|
|
path.display()
|
|
));
|
|
continue;
|
|
}
|
|
passed += 1;
|
|
}
|
|
|
|
if !failures.is_empty() {
|
|
panic!(
|
|
"{} of {} .ail fixture(s) failed idempotency check (passed: {}):\n{}",
|
|
failures.len(),
|
|
fixtures.len(),
|
|
passed,
|
|
failures.join("\n\n")
|
|
);
|
|
}
|
|
eprintln!("parse→print→parse idempotency ok for {passed} .ail fixtures");
|
|
}
|
|
|
|
/// prep.2 (kernel-extension-mechanics): pins the Form-A round-trip for
|
|
/// `(new T <type-arg> <value-arg>)`. The fixture exercises the
|
|
/// `parse_new` dispatcher + the `write_term` Term::New arm, including
|
|
/// the Type-vs-Value arg disambiguation on the parser side (head
|
|
/// keyword `con` → NewArg::Type; bare integer → NewArg::Value).
|
|
///
|
|
/// Inline rather than `examples/*.ail` because no existing fixture
|
|
/// uses `Term::New` and the iter's out-of-scope note explicitly
|
|
/// declines a fixture migration in prep.2 (the construct will only
|
|
/// see real fixtures once raw-buf's codegen lands).
|
|
#[test]
|
|
fn round_trip_term_new_mixed_args() {
|
|
// (new Series (con Float) 3) — a Type-positional arg followed by
|
|
// a Value-positional arg. The body lives inside an otherwise
|
|
// minimal module shell that the printer emits in canonical form.
|
|
let form_a = "(module new_demo\n (data Series\n (ctor MkSeries (con Int)))\n (fn new\n (type (fn-type (params (own (con Int))) (ret (own (con Series)))))\n (params n)\n (body (term-ctor Series MkSeries n)))\n (fn main\n (type (fn-type (params) (ret (own (con Series)))))\n (params)\n (body (new Series (con Float) 3))))";
|
|
let parsed_once = ailang_surface::parse(form_a)
|
|
.expect("Form-A round-trip fixture must parse");
|
|
let printed = ailang_surface::print(&parsed_once);
|
|
let parsed_twice = ailang_surface::parse(&printed).expect("print(parse(x)) must re-parse");
|
|
let bytes_a = ailang_core::canonical::to_bytes(&parsed_once);
|
|
let bytes_b = ailang_core::canonical::to_bytes(&parsed_twice);
|
|
assert_eq!(
|
|
bytes_a, bytes_b,
|
|
"Term::New round-trip parse→print→parse is not idempotent.\nfirst: {}\nprint: {printed}\nsecond: {}",
|
|
String::from_utf8_lossy(&bytes_a),
|
|
String::from_utf8_lossy(&bytes_b),
|
|
);
|
|
}
|
|
|
|
/// Parse-determinism (post-form-a-default-authoring §C3): for every
|
|
/// well-formed `.ail` text `t`, `parse(t)` produces the same canonical
|
|
/// bytes every invocation. Loader is a pure function of input — no
|
|
/// randomness, no time dependence, no environment leak.
|
|
#[test]
|
|
fn parse_is_deterministic_over_every_ail_fixture() {
|
|
let mut checked = 0usize;
|
|
for ail_path in list_ail_fixtures() {
|
|
let text = std::fs::read_to_string(&ail_path)
|
|
.unwrap_or_else(|e| panic!("read {ail_path:?}: {e}"));
|
|
let m1 = ailang_surface::parse(&text)
|
|
.unwrap_or_else(|e| panic!("parse {ail_path:?}: {e:?}"));
|
|
let m2 = ailang_surface::parse(&text)
|
|
.unwrap_or_else(|e| panic!("parse {ail_path:?} (2nd): {e:?}"));
|
|
let b1 = ailang_core::canonical::to_bytes(&m1);
|
|
let b2 = ailang_core::canonical::to_bytes(&m2);
|
|
assert_eq!(
|
|
b1, b2,
|
|
"parse non-determinism on {ail_path:?}",
|
|
);
|
|
checked += 1;
|
|
}
|
|
assert!(checked > 0, "no .ail fixtures found");
|
|
}
|
|
|
|
/// prep.3 (kernel-extension-mechanics): the `(kernel)` module-header
|
|
/// attribute round-trips Form-A → JSON → Form-A bit-identical and
|
|
/// preserves the parsed bool through both halves.
|
|
#[test]
|
|
fn kernel_module_header_round_trips() {
|
|
let src = "(module k\n (kernel))\n";
|
|
let m = ailang_surface::parse(src).expect("Form-A parse of (kernel) module");
|
|
assert!(m.kernel, "parsed kernel: true");
|
|
|
|
let printed = ailang_surface::print(&m);
|
|
// Round-trip: print, re-parse, kernel still true.
|
|
let m2 = ailang_surface::parse(&printed).expect("re-parse printed form");
|
|
assert!(m2.kernel, "kernel: true survives round-trip");
|
|
|
|
// Default form (no (kernel)) stays kernel: false.
|
|
let plain = ailang_surface::parse("(module p)\n").expect("Form-A parse plain module");
|
|
assert!(!plain.kernel);
|
|
}
|
|
|
|
/// prep.3 (kernel-extension-mechanics): the `(param-in (var t1 t2 ...))`
|
|
/// TypeDef attribute round-trips Form-A → JSON → Form-A bit-
|
|
/// identical and preserves the parsed BTreeMap through both halves,
|
|
/// including the BTreeSet's deterministic alphabetical iteration
|
|
/// order.
|
|
#[test]
|
|
fn typedef_param_in_round_trips_surface() {
|
|
let src = concat!(
|
|
"(module m\n",
|
|
" (data T (vars a)\n",
|
|
" (ctor MkT a)\n",
|
|
" (param-in (a Int Float))))\n",
|
|
);
|
|
let m = ailang_surface::parse(src).expect("Form-A parse of (param-in ...)");
|
|
let td = match &m.defs[0] {
|
|
ailang_core::ast::Def::Type(t) => t,
|
|
_ => panic!("expected Type def"),
|
|
};
|
|
let allowed = td.param_in.get("a").expect("var `a` bound in param_in");
|
|
assert!(allowed.contains("Int"));
|
|
assert!(allowed.contains("Float"));
|
|
assert_eq!(allowed.len(), 2);
|
|
|
|
let printed = ailang_surface::print(&m);
|
|
let m2 = ailang_surface::parse(&printed).expect("re-parse printed form");
|
|
let td2 = match &m2.defs[0] {
|
|
ailang_core::ast::Def::Type(t) => t,
|
|
_ => panic!("expected Type def after round-trip"),
|
|
};
|
|
assert_eq!(td2.param_in, td.param_in, "param_in survives round-trip");
|
|
}
|