2114fb2f6a
Vier Tooling-Subkommandos können jetzt auf dem ganzen Workspace
arbeiten. Default bleibt single-modul (Rückwärtskompat). Manifest
listet alphabetisch nach (modul, name) mit module-Feld; describe
nimmt Punktnotation ws_lib.add an, ambig-Erkennung fallback;
deps gibt Cross-Module-Edges {from_module,from_def,to_module,to_def}
aus, Effekt-Ops separat; diff vergleicht workspace-übergreifend mit
added/removed/changed/unchanged_modules und nested Sub-Diff pro
changed_module. Helper diff_def_lists wird von Single- und
Workspace-Diff geteilt.
510 lines
18 KiB
Rust
510 lines
18 KiB
Rust
//! End-to-End-Test: lade Beispielmodul, kompiliere zu Binary, führe es aus.
|
|
//!
|
|
//! Dieses Test schützt die wichtigste Eigenschaft der gesamten Pipeline:
|
|
//! AST → typecheck → LLVM IR → clang → Binary → korrekter 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 liegt zwei Ebenen über dem 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");
|
|
}
|
|
|
|
/// Schützt das Block-Tracking im Codegen: max3 hat verschachtelte `if`s,
|
|
/// und falsches phi-Block-Tracking würde hier zu falschem Ergebnis führen.
|
|
#[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.");
|
|
}
|
|
|
|
/// Schützt ADT-Codegen + Match: rekursive Liste, sum_list via match auf Cons/Nil.
|
|
#[test]
|
|
fn list_sum_via_match() {
|
|
let stdout = build_and_run("list.ail.json");
|
|
assert_eq!(stdout.trim(), "42");
|
|
}
|
|
|
|
/// Schützt `ail diff`: ein modifizierter Body ändert den Hash von `sum`,
|
|
/// während `main` unverändert bleibt. Erwartet Exit-Code 1, `changed`
|
|
/// enthält genau `sum`, `unchanged` enthält `main`, `added`/`removed` leer.
|
|
#[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");
|
|
|
|
// Variante: lade sum.ail.json, mutiere den `then`-Zweig (statt 0 nun 1)
|
|
// in der `sum`-Definition. `main` bleibt bitidentisch.
|
|
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") {
|
|
// Ersetze den then-Zweig 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 eines Moduls mit sich selbst: Exit 0, alle Listen außer `unchanged` leer.
|
|
#[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"
|
|
);
|
|
}
|
|
|
|
/// Schützt den Workspace-Loader (Iter 5a): das Eintrittsmodul `ws_main`
|
|
/// importiert `ws_lib`, beide müssen vom Loader gefunden, geladen und im
|
|
/// JSON-Output aufgelistet sein. Cross-Module-Typcheck/Codegen ist explizit
|
|
/// nicht Teil dieses Tests — er verifiziert nur die Lader-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:?}");
|
|
}
|
|
|
|
/// Schützt Iter 5b: `ail check examples/ws_main.ail.json --json` muss den
|
|
/// Cross-Module-Aufruf `ws_lib.add` auflösen können. Erwartet: Exit 0,
|
|
/// stdout exakt `[]` (leeres 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");
|
|
}
|
|
|
|
/// Schützt Iter 5c (Cross-Module-Codegen): `ail build` über
|
|
/// `examples/ws_main.ail.json` muss das Workspace inkl. `ws_lib` lowern,
|
|
/// `@ail_ws_main_main` muss `@ail_ws_lib_add(2,3)` aufrufen und `5` drucken.
|
|
#[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");
|
|
}
|
|
|
|
/// Schützt Iter 5d: `ail manifest --workspace --json` listet Defs aus allen
|
|
/// Modulen des Workspaces — sichtbar an einem `module`-Feld pro Eintrag.
|
|
#[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:?}"
|
|
);
|
|
}
|
|
|
|
/// Schützt Iter 5d: `ail describe --workspace ws_lib.add` löst die
|
|
/// qualifizierte Punkt-Notation in das tatsächlich importierte Modul auf.
|
|
#[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}"
|
|
);
|
|
}
|
|
|
|
/// Schützt Iter 5d: `ail deps --workspace` enthält eine Cross-Module-Edge
|
|
/// `ws_main.main -> ws_lib.add`, weil `ws_main` `ws_lib.add(2,3)` aufruft.
|
|
#[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}"
|
|
);
|
|
}
|
|
|
|
/// Schützt Iter 5d: `ail diff --workspace` erkennt ein zusätzliches Modul
|
|
/// im B-Workspace als `added_modules`-Eintrag und exitet mit 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();
|
|
|
|
// Beide Workspaces haben ein leeres Modul `root`. B importiert
|
|
// zusätzlich `extra`. Loader-Konvention: `<root_dir>/<name>.ail.json`,
|
|
// Modul-Name muss zum Dateinamen passen.
|
|
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}"
|
|
);
|
|
}
|
|
|
|
/// Schützt das `--json`-Diagnostic-Format für Tooling-Konsumenten.
|
|
/// `broken_unbound.ail.json` referenziert eine nicht-existente Variable;
|
|
/// erwartet wird Exit-Code 1 und mindestens ein Diagnostic mit
|
|
/// `severity == "error"` und `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}"
|
|
);
|
|
}
|