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:
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 } }
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user