Files
AILang/crates/ail/tests/e2e.rs
T
Brummel 7577ab8a90 Translate project content to English
Make English the project-wide language: all comments, string literals,
CLI help text, design docs, journal, agent prompts, and README. Only
CLAUDE.md (the user's own instruction file) stays German, and the live
conversation between user and Claude continues in German.

Adds a new "Project language: English" section to docs/DESIGN.md as
the durable convention. No logic changes — translation only. Four
internal error strings in the typechecker were retranslated; no
test asserts on their wording.

Verified: cargo test --workspace passes (44/44).

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

510 lines
18 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(), "Hallo, 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");
}
/// 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 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}"
);
}