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 <entry> [--json] listet erreichbare Module mit
module_hash und Def-Count. Bestehende Subkommandos arbeiten weiter
pro Einzelmodul (Iter 5d).
This commit is contained in:
2026-05-07 11:23:21 +02:00
parent 7619f20cd6
commit 3451b5bd15
6 changed files with 475 additions and 0 deletions
+65
View File
@@ -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} {:<width$} {} {:>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();
+42
View File
@@ -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
+2
View File
@@ -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 {
+308
View File
@@ -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 `<root_dir>/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<String, Module>,
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<String> },
#[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<Workspace, WorkspaceLoadError> {
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<String, Module> = BTreeMap::new();
let mut visiting: Vec<String> = Vec::new();
let mut visiting_set: HashSet<String> = 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<String, Module>,
visiting: &mut Vec<String>,
visiting_set: &mut HashSet<String>,
) -> 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<Module, WorkspaceLoadError> {
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: `<name>.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<serde_json::Value> = 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:?}"),
}
}
}
+30
View File
@@ -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" }
]
}
}
]
}
+28
View File
@@ -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 } }
]
}
}
]
}