From 3451b5bd1529b4643ca79a51b9a061364403454c Mon Sep 17 00:00:00 2001 From: Brummel Date: Thu, 7 May 2026 11:23:21 +0200 Subject: [PATCH] Iter 5a: Workspace-Loader mit Imports ailang_core::Workspace + load_workspace folgt imports-Feld rekursiv vom Eintrittsmodul. DFS mit Zyklus-Erkennung; Konvention: Modulname == Dateiname (.ail.json). WorkspaceLoadError sammelt strukturiert: Cycle, ModuleNotFound, ModuleNameMismatch, Schema, Io. Neues ail workspace [--json] listet erreichbare Module mit module_hash und Def-Count. Bestehende Subkommandos arbeiten weiter pro Einzelmodul (Iter 5d). --- crates/ail/src/main.rs | 65 ++++++ crates/ail/tests/e2e.rs | 42 ++++ crates/ailang-core/src/lib.rs | 2 + crates/ailang-core/src/workspace.rs | 308 ++++++++++++++++++++++++++++ examples/ws_lib.ail.json | 30 +++ examples/ws_main.ail.json | 28 +++ 6 files changed, 475 insertions(+) create mode 100644 crates/ailang-core/src/workspace.rs create mode 100644 examples/ws_lib.ail.json create mode 100644 examples/ws_main.ail.json diff --git a/crates/ail/src/main.rs b/crates/ail/src/main.rs index e42aad1..cb490c4 100644 --- a/crates/ail/src/main.rs +++ b/crates/ail/src/main.rs @@ -83,6 +83,16 @@ enum Cmd { #[arg(long)] json: bool, }, + /// Lädt einen Workspace (Eintrittsmodul + transitive Imports) und + /// listet alle erreichbaren Module mit Hash und Def-Anzahl. + /// + /// Iter 5a: nur das Listing. Cross-Module-Typcheck/Codegen folgt in + /// 5b/5c; bestehende Subkommandos arbeiten weiter pro Einzelmodul. + Workspace { + entry: PathBuf, + #[arg(long)] + json: bool, + }, } fn main() -> Result<()> { @@ -286,6 +296,61 @@ fn main() -> Result<()> { std::process::exit(1); } } + Cmd::Workspace { entry, json } => { + let ws = ailang_core::load_workspace(&entry)?; + // Alphabetisch über Modul-Namen iterieren (BTreeMap-Order ist + // bereits sortiert; explizit absichern). + let mut entries: Vec<(String, String, usize)> = ws + .modules + .iter() + .map(|(name, m)| { + ( + name.clone(), + ailang_core::module_hash(m), + m.defs.len(), + ) + }) + .collect(); + entries.sort_by(|a, b| a.0.cmp(&b.0)); + + if json { + let arr: Vec<_> = entries + .iter() + .map(|(name, hash, defs)| { + serde_json::json!({ + "name": name, + "hash": hash, + "defs": defs, + }) + }) + .collect(); + let out = serde_json::json!({ + "entry": ws.entry, + "modules": arr, + }); + println!("{}", serde_json::to_string_pretty(&out)?); + } else { + // Spaltenbreite an längstem Modulnamen ausrichten. Erste Zeile + // markiert das Eintrittsmodul mit `*`. + let name_width = entries + .iter() + .map(|(n, _, _)| n.len()) + .max() + .unwrap_or(0) + .max(6); + println!("entry: {}", ws.entry); + for (name, hash, defs) in &entries { + let marker = if *name == ws.entry { "*" } else { " " }; + println!( + "{marker} {:3} defs", + name, + hash, + defs, + width = name_width, + ); + } + } + } Cmd::Deps { path, of, json } => { let m = ailang_core::load_module(&path)?; let mut entries = Vec::new(); diff --git a/crates/ail/tests/e2e.rs b/crates/ail/tests/e2e.rs index 7369a58..c93ad0d 100644 --- a/crates/ail/tests/e2e.rs +++ b/crates/ail/tests/e2e.rs @@ -187,6 +187,48 @@ fn diff_no_changes_exit_zero() { ); } +/// 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 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 diff --git a/crates/ailang-core/src/lib.rs b/crates/ailang-core/src/lib.rs index 1a4005f..45cc23f 100644 --- a/crates/ailang-core/src/lib.rs +++ b/crates/ailang-core/src/lib.rs @@ -7,11 +7,13 @@ pub mod ast; pub mod canonical; pub mod hash; pub mod pretty; +pub mod workspace; pub use ast::{ def_kind, def_name, ConstDef, Def, FnDef, Import, Literal, Module, Term, Type, }; pub use hash::def_hash; +pub use workspace::{load_workspace, module_hash, Workspace, WorkspaceLoadError}; #[derive(Debug, thiserror::Error)] pub enum Error { diff --git a/crates/ailang-core/src/workspace.rs b/crates/ailang-core/src/workspace.rs new file mode 100644 index 0000000..86d094c --- /dev/null +++ b/crates/ailang-core/src/workspace.rs @@ -0,0 +1,308 @@ +//! Workspace-Loader: lädt ein Eintrittsmodul und folgt rekursiv dessen +//! `imports`. Konvention: ein `import { module: "foo" }` wird relativ zum +//! Verzeichnis des Eintrittsfiles als `/foo.ail.json` aufgelöst. +//! +//! Iter 5a hat die Verantwortung, alle erreichbaren Module zu **finden** und +//! konsistent zu **laden**. Cross-Modul-Typcheck (5b) und -Codegen (5c) bauen +//! darauf auf, sind aber nicht Teil dieses Schritts. + +use crate::ast::Module; +use crate::canonical; +use crate::{load_module, Error as CoreError}; +use std::collections::{BTreeMap, HashSet}; +use std::path::{Path, PathBuf}; + +/// Vollständig geladener Workspace. +/// +/// `entry` benennt das Eintrittsmodul (Modulname, **nicht** Pfad). Alle +/// transitiv erreichbaren Module sind in `modules` enthalten und per +/// `Module.name` indiziert. `root_dir` ist das Verzeichnis, in dem das +/// Eintrittsfile liegt; alle Imports werden relativ dazu aufgelöst. +#[derive(Debug, Clone)] +pub struct Workspace { + pub entry: String, + pub modules: BTreeMap, + pub root_dir: PathBuf, +} + +/// Strukturierte Fehler des Workspace-Loaders. +/// +/// `Cycle.path` ist die Kette der Modul-Namen, in der der Zyklus geschlossen +/// wurde — das letzte Element ist der bereits in `visiting` enthaltene Name. +#[derive(Debug, thiserror::Error)] +pub enum WorkspaceLoadError { + #[error("io error for {path}: {source}")] + Io { + path: PathBuf, + #[source] + source: std::io::Error, + }, + + #[error("schema/parse error in {path}: {source}")] + Schema { + path: PathBuf, + #[source] + source: CoreError, + }, + + #[error("module `{name}` not found (expected at {expected_path})")] + ModuleNotFound { name: String, expected_path: PathBuf }, + + #[error( + "module name in file ({name_in_file:?}) does not match expected name from path ({name_from_path:?})" + )] + ModuleNameMismatch { + name_in_file: String, + name_from_path: String, + }, + + #[error("import cycle detected: {}", path.join(" -> "))] + Cycle { path: Vec }, + + #[error( + "module `{name}` was loaded twice with differing content (hashes differ)" + )] + ModuleHashMismatch { name: String }, +} + +/// Hash über die kanonischen Bytes eines kompletten Moduls. +/// +/// Parallel zu `def_hash`, aber auf Modul-Ebene. Der Workspace-Loader nutzt +/// das, um Doppelladungen zu verifizieren; die CLI nutzt das, um pro Modul +/// einen stabilen Identifier auszugeben. +pub fn module_hash(m: &Module) -> String { + let bytes = canonical::to_bytes(m); + let h = blake3::hash(&bytes); + h.to_hex().as_str()[..16].to_string() +} + +/// Lade Eintrittsmodul plus alle transitiv erreichbaren Module. +/// +/// Algorithmus: DFS über `imports`, mit zwei Mengen: +/// - `loaded` (= `modules`-Map): Module, deren Subtree bereits vollständig +/// abgearbeitet ist. Beim erneuten Treffen wird nur Hash-Konsistenz geprüft. +/// - `visiting`: Stack von Modulen, deren DFS-Abstieg noch läuft. Treffer +/// hier = Zyklus. +pub fn load_workspace(entry_path: &Path) -> Result { + let entry_path = entry_path.to_path_buf(); + let root_dir = entry_path + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(".")); + + // Eintrittsmodul laden + Konventions-Check Name<->Dateiname. + let entry_module = load_one(&entry_path)?; + let expected_entry_name = module_name_from_path(&entry_path); + if entry_module.name != expected_entry_name { + return Err(WorkspaceLoadError::ModuleNameMismatch { + name_in_file: entry_module.name.clone(), + name_from_path: expected_entry_name, + }); + } + + let entry_name = entry_module.name.clone(); + + let mut modules: BTreeMap = BTreeMap::new(); + let mut visiting: Vec = Vec::new(); + let mut visiting_set: HashSet = HashSet::new(); + + visit( + entry_module, + &root_dir, + &mut modules, + &mut visiting, + &mut visiting_set, + )?; + + Ok(Workspace { + entry: entry_name, + modules, + root_dir, + }) +} + +fn visit( + module: Module, + root_dir: &Path, + modules: &mut BTreeMap, + visiting: &mut Vec, + visiting_set: &mut HashSet, +) -> Result<(), WorkspaceLoadError> { + let name = module.name.clone(); + + // Schon abgeschlossen geladen? Dann nichts tun. Hash-Konsistenz wird beim + // Wieder-Antreffen über Imports geprüft (siehe unten in der Schleife). + if modules.contains_key(&name) { + return Ok(()); + } + + // Zyklus: derselbe Name liegt aktuell auf dem DFS-Stack. + if visiting_set.contains(&name) { + let mut path = visiting.clone(); + path.push(name); + return Err(WorkspaceLoadError::Cycle { path }); + } + + visiting.push(name.clone()); + visiting_set.insert(name.clone()); + + // Imports rekursiv abarbeiten. + let imports = module.imports.clone(); + for imp in &imports { + let imp_path = root_dir.join(format!("{}.ail.json", imp.module)); + + if let Some(existing) = modules.get(&imp.module) { + // Schon vollständig geladen — Hash-Konsistenz prüfen, falls die + // Datei auf Platte sich geändert hat. + let on_disk = match load_one(&imp_path) { + Ok(m) => m, + Err(WorkspaceLoadError::Io { .. }) => continue, + Err(e) => return Err(e), + }; + if module_hash(existing) != module_hash(&on_disk) { + return Err(WorkspaceLoadError::ModuleHashMismatch { + name: imp.module.clone(), + }); + } + continue; + } + + if visiting_set.contains(&imp.module) { + let mut path = visiting.clone(); + path.push(imp.module.clone()); + return Err(WorkspaceLoadError::Cycle { path }); + } + + if !imp_path.exists() { + return Err(WorkspaceLoadError::ModuleNotFound { + name: imp.module.clone(), + expected_path: imp_path, + }); + } + + let imported = load_one(&imp_path)?; + if imported.name != imp.module { + return Err(WorkspaceLoadError::ModuleNameMismatch { + name_in_file: imported.name, + name_from_path: imp.module.clone(), + }); + } + visit(imported, root_dir, modules, visiting, visiting_set)?; + } + + visiting.pop(); + visiting_set.remove(&name); + modules.insert(name, module); + + Ok(()) +} + +fn load_one(path: &Path) -> Result { + match load_module(path) { + Ok(m) => Ok(m), + Err(CoreError::Io(e)) => Err(WorkspaceLoadError::Io { + path: path.to_path_buf(), + source: e, + }), + Err(e) => Err(WorkspaceLoadError::Schema { + path: path.to_path_buf(), + source: e, + }), + } +} + +fn module_name_from_path(p: &Path) -> String { + let file = p.file_name().and_then(|s| s.to_str()).unwrap_or(""); + // Konvention: `.ail.json`. + if let Some(stripped) = file.strip_suffix(".ail.json") { + return stripped.to_string(); + } + // Fallback: nur die letzte Extension entfernen. + Path::new(file) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or(file) + .to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn write_module(dir: &Path, name: &str, imports: &[&str]) -> PathBuf { + let imports_json: Vec = imports + .iter() + .map(|m| serde_json::json!({ "module": m })) + .collect(); + let module = serde_json::json!({ + "schema": crate::SCHEMA, + "name": name, + "imports": imports_json, + "defs": [], + }); + let path = dir.join(format!("{name}.ail.json")); + fs::write(&path, serde_json::to_vec_pretty(&module).unwrap()).unwrap(); + path + } + + fn tmp_dir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!( + "ailang_workspace_test_{tag}_{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&d); + fs::create_dir_all(&d).unwrap(); + d + } + + #[test] + fn loads_example_workspace_happy_path() { + // Nutzt die kanonischen Beispiel-Files unter `examples/`. Dieser + // Test dokumentiert, dass der Loader sich auf das committete + // Workspace-Beispiel verlässt. + let manifest_dir = env!("CARGO_MANIFEST_DIR"); + let workspace_root = + Path::new(manifest_dir).parent().unwrap().parent().unwrap(); + let entry = workspace_root.join("examples").join("ws_main.ail.json"); + + let ws = load_workspace(&entry).expect("load workspace"); + assert_eq!(ws.entry, "ws_main"); + assert!(ws.modules.contains_key("ws_main")); + assert!(ws.modules.contains_key("ws_lib")); + assert_eq!(ws.modules.len(), 2); + } + + #[test] + fn detects_import_cycle() { + let dir = tmp_dir("cycle"); + write_module(&dir, "a", &["b"]); + write_module(&dir, "b", &["a"]); + let entry = dir.join("a.ail.json"); + + let err = load_workspace(&entry).expect_err("must error on cycle"); + match err { + WorkspaceLoadError::Cycle { path } => { + assert!(path.contains(&"a".to_string())); + assert!(path.contains(&"b".to_string())); + } + other => panic!("expected Cycle, got {other:?}"), + } + } + + #[test] + fn module_not_found_yields_structured_error() { + let dir = tmp_dir("notfound"); + write_module(&dir, "main", &["does_not_exist"]); + let entry = dir.join("main.ail.json"); + + let err = load_workspace(&entry).expect_err("must error on missing module"); + match err { + WorkspaceLoadError::ModuleNotFound { name, expected_path } => { + assert_eq!(name, "does_not_exist"); + assert!(expected_path.ends_with("does_not_exist.ail.json")); + } + other => panic!("expected ModuleNotFound, got {other:?}"), + } + } +} diff --git a/examples/ws_lib.ail.json b/examples/ws_lib.ail.json new file mode 100644 index 0000000..bcb8ab1 --- /dev/null +++ b/examples/ws_lib.ail.json @@ -0,0 +1,30 @@ +{ + "schema": "ailang/v0", + "name": "ws_lib", + "imports": [], + "defs": [ + { + "kind": "fn", + "name": "add", + "type": { + "k": "fn", + "params": [ + { "k": "con", "name": "Int" }, + { "k": "con", "name": "Int" } + ], + "ret": { "k": "con", "name": "Int" }, + "effects": [] + }, + "params": ["a", "b"], + "doc": "Iter 5a fixture: einfache Add-Funktion, die ws_main importiert.", + "body": { + "t": "app", + "fn": { "t": "var", "name": "+" }, + "args": [ + { "t": "var", "name": "a" }, + { "t": "var", "name": "b" } + ] + } + } + ] +} diff --git a/examples/ws_main.ail.json b/examples/ws_main.ail.json new file mode 100644 index 0000000..d4d4293 --- /dev/null +++ b/examples/ws_main.ail.json @@ -0,0 +1,28 @@ +{ + "schema": "ailang/v0", + "name": "ws_main", + "imports": [ + { "module": "ws_lib" } + ], + "defs": [ + { + "kind": "fn", + "name": "main", + "type": { + "k": "fn", + "params": [], + "ret": { "k": "con", "name": "Unit" }, + "effects": ["IO"] + }, + "params": [], + "doc": "Iter 5a fixture: Eintrittsmodul, importiert ws_lib (noch ungenutzt — Cross-Module-Aufruf folgt in 5b/5c).", + "body": { + "t": "do", + "op": "io/print_int", + "args": [ + { "t": "lit", "lit": { "kind": "int", "value": 0 } } + ] + } + } + ] +}