Files
AILang/crates/ailang-core/src/workspace.rs
T
Brummel c90926dbba Iter 13d: rustdoc polish for ailang-core + new docwriter agent
Adds ailang-docwriter to /agents/ — a recurring role for keeping
crate-, module-, and pub-item-level rustdoc accurate. First mission:
ailang-core. Crate root, every module root, every pub item documented;
intra-doc links throughout; Iter-13a additions (TypeDef.vars, Type::Con.args)
get an explicit backwards-compat note. Two stale broken-link warnings in
ailang-check fixed in passing. cargo doc --no-deps now warning-free across
the workspace; promoted to verification invariant 6 in DESIGN.md.
2026-05-07 15:02:35 +02:00

356 lines
12 KiB
Rust

//! Workspace loader: loads an entry module and recursively follows its
//! `imports`.
//!
//! Convention: an `import { module: "foo" }` is resolved relative to
//! the entry file's directory as `<root_dir>/foo.ail.json`. Module
//! names must match the file stem (the loader rejects mismatches).
//!
//! The single entry point is [`load_workspace`]; it returns a fully
//! populated [`Workspace`] or a structured [`WorkspaceLoadError`].
//! [`module_hash`] is the module-granularity counterpart to
//! [`crate::def_hash`] and is used both internally (to detect a module
//! re-loaded from disk with different content) and by the CLI.
//!
//! This module is responsible only for **finding** and consistently
//! **loading** all reachable modules. Cross-module typechecking lives
//! in `ailang-check`; codegen in `ailang-codegen`. Neither is run from
//! here.
//!
//! # Examples
//!
//! ```ignore
//! use ailang_core::load_workspace;
//! use std::path::Path;
//!
//! let ws = load_workspace(Path::new("examples/ws_main.ail.json"))?;
//! assert_eq!(ws.entry, "ws_main");
//! // All transitively imported modules are now in `ws.modules`.
//! for (name, m) in &ws.modules {
//! println!("{name}: {} defs", m.defs.len());
//! }
//! # Ok::<(), ailang_core::workspace::WorkspaceLoadError>(())
//! ```
use crate::ast::Module;
use crate::canonical;
use crate::{load_module, Error as CoreError};
use std::collections::{BTreeMap, HashSet};
use std::path::{Path, PathBuf};
/// Fully loaded workspace.
///
/// `entry` names the entry module (module name, **not** a path). All
/// transitively reachable modules are contained in `modules` and indexed
/// by `Module.name`. `root_dir` is the directory the entry file lives
/// in; all imports are resolved relative to it.
#[derive(Debug, Clone)]
pub struct Workspace {
/// Name of the entry module (the one passed to [`load_workspace`]).
pub entry: String,
/// Every module reachable from `entry`, indexed by module name.
/// `BTreeMap` is used so iteration order is deterministic, which
/// matters for downstream codegen and reporting.
pub modules: BTreeMap<String, Module>,
/// Directory the entry file lives in; all imports are resolved
/// relative to it.
pub root_dir: PathBuf,
}
/// Structured errors of the workspace loader.
///
/// `Cycle.path` is the chain of module names in which the cycle was
/// closed — the last element is the name already present in `visiting`.
#[derive(Debug, thiserror::Error)]
pub enum WorkspaceLoadError {
/// File I/O failed while reading a module file (typically: file
/// missing, permission denied).
#[error("io error for {path}: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
/// File contents failed to parse as a [`Module`] or had the wrong
/// schema tag. Wraps a [`CoreError`] (the single-module loader's
/// error type).
#[error("schema/parse error in {path}: {source}")]
Schema {
path: PathBuf,
#[source]
source: CoreError,
},
/// An `import { module: "foo" }` did not resolve to an existing
/// file at the expected path.
#[error("module `{name}` not found (expected at {expected_path})")]
ModuleNotFound { name: String, expected_path: PathBuf },
/// The `name` field in a loaded module file disagrees with the
/// file-stem-derived name the loader expected (i.e. the
/// `<name>.ail.json` convention is broken).
#[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,
},
/// An import cycle was detected. `path` is the chain of module
/// names in which the cycle was closed — the last element is the
/// name that was already on the visit stack.
#[error("import cycle detected: {}", path.join(" -> "))]
Cycle { path: Vec<String> },
/// A module was reachable through two import paths, and its
/// on-disk content (compared via [`module_hash`]) differs between
/// the two reads. This typically means the file changed mid-load.
#[error(
"module `{name}` was loaded twice with differing content (hashes differ)"
)]
ModuleHashMismatch { name: String },
}
/// Hash over the canonical bytes of a complete module.
///
/// Parallel to `def_hash`, but at module level. The workspace loader
/// uses this to verify double-loads; the CLI uses it to emit a stable
/// per-module identifier.
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()
}
/// Load entry module plus all transitively reachable modules.
///
/// Algorithm: DFS over `imports`, with two sets:
/// - `loaded` (= `modules` map): modules whose subtree is already fully
/// processed. On a re-hit only hash consistency is checked.
/// - `visiting`: stack of modules whose DFS descent is still running.
/// A hit here = cycle.
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("."));
// Load entry module + check name<->filename convention.
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();
// Already fully loaded? Then do nothing. Hash consistency is checked
// on re-encounter via imports (see below in the loop).
if modules.contains_key(&name) {
return Ok(());
}
// Cycle: same name currently on the 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());
// Process imports recursively.
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) {
// Already fully loaded — check hash consistency, in case the
// file on disk has changed.
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("");
// Convention: `<name>.ail.json`.
if let Some(stripped) = file.strip_suffix(".ail.json") {
return stripped.to_string();
}
// Fallback: strip only the last extension.
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() {
// Uses the canonical example files under `examples/`. This
// test documents that the loader relies on the committed
// workspace example.
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:?}"),
}
}
}