Files
AILang/crates/ail/tests/e2e.rs
T
Brummel efd209779c Translate remaining German content to English
Finishes the project-wide English convention:
- CLAUDE.md (the user opted in to translate it too).
- examples/hello.ail.json: "Hallo, AILang." -> "Hello, AILang.".
- E2E test assertion and IR snapshot for hello regenerated to match.
- DESIGN.md "Project language: English" section: the previous
  CLAUDE.md exception is dropped.

After this commit, no German remains in any committed file
(grep -P '[äöüÄÖÜß]' is empty across .rs / .md / .json / .ll).

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 12:20:18 +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(), "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");
}
/// 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}"
);
}