c90926dbba
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.
356 lines
12 KiB
Rust
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:?}"),
|
|
}
|
|
}
|
|
}
|