1d24907f84
examples/sort.ail.json: insertion sort over IntList. Defines `insert :: Int -> IntList -> IntList` and `sort :: IntList -> IntList` recursively, plus print_list using Iter 10's seq. Sorts an 11-element input and prints `1 1 2 3 3 4 5 5 5 6 9` one-per-line. Validates that the language handles deeper ADT recursion + branching (if + <=) + ctor construction + IO sequencing without surprises. Wrote, typechecked, ran first try. The Iter 11 plan called for polymorphism, but on reflection the right move was one more validation cycle before disturbing the pipeline. Polymorphism is now queued explicitly as Iter 12 (12a typechecker substitution, 12b codegen monomorphisation, 12c docs + generic example). Tests: 52 green (was 51). New e2e `insertion_sort_orders_list`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
621 lines
22 KiB
Rust
621 lines
22 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 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 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"],
|
|
);
|
|
}
|
|
|
|
/// 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}"
|
|
);
|
|
}
|