iter ext-cli.1: CLI accepts .ail (Form A) sources alongside .ail.json
Closes the loop opened by this morning's .ailx → .ail rename. Every
path-taking ail subcommand (check, build, run, manifest, render,
prose, merge-prose, describe, emit-ir, diff, workspace, deps) now
accepts a .ail (Form A) input as well as .ail.json (Form B). For
.ail paths the subcommand parses through ailang_surface::parse
in-line and proceeds with the same loaded Module value .ail.json
would have produced; binary semantics are extension-agnostic.
Architecture: a new ailang_surface::{load_module, load_workspace}
pair dispatches on file extension. A new injection point
ailang_core::workspace::load_workspace_with<F> lets the surface
crate plug in an extension-aware loader without core having to
import surface (which would close a crate cycle). Import resolution
in workspace::visit now prefers a .ail sibling over a .ail.json
sibling. The CLI binary's 18 callsites switched to the surface
loaders. Surface parse failures route through
workspace_error_to_diagnostic as a new SurfaceParse variant of
WorkspaceLoadError, so `ail check --json foo.ail` on a malformed
.ail returns a parseable JSON diagnostic with code
surface-parse-error instead of the misleading
`json: expected value at line 1 column 1` fall-through.
Boss pre-commit correction: the implementer followed the plan
verbatim and used snake_case `surface_parse_error`, but every
existing diagnostic code in the codebase is kebab-case (verified
via `git grep` over crates/ailang-check/src and crates/ail/src);
realigned to `surface-parse-error` at three sites (the diagnostic
arm + two ct1 test strings). Journal Concerns section records
the correction.
Tests: cargo test --workspace 487 green (+7 from this iter:
1 core unit, 4 surface integration, 1 ail E2E pair, 1 ct1 CLI
diagnostic-shape). bench/check.py, compile_check.py, cross_lang.py
clean on re-run; the latency.{explicit,implicit}_at_rc tail
cluster occasionally flagged on first runs is the same
nondeterministic cluster the previous two audits documented;
baseline left pristine for the third consecutive iter.
Roadmap follow-up "ail check/build/run accept .ail extension" (P2)
removed.
DESIGN.md §Decision 6 / "CLI" bullet gained the symmetric upward
sentence: both .ail and .ail.json are first-class CLI inputs.
This commit is contained in:
@@ -10,3 +10,6 @@ serde_json.workspace = true
|
||||
blake3.workspace = true
|
||||
thiserror.workspace = true
|
||||
indexmap.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
|
||||
@@ -157,6 +157,18 @@ pub enum WorkspaceLoadError {
|
||||
source: CoreError,
|
||||
},
|
||||
|
||||
/// Source file failed to parse via the surface crate.
|
||||
///
|
||||
/// Used when a `.ail` (Form A) input is given to a path-taking
|
||||
/// subcommand. The message is the formatted `ailang_surface::ParseError`
|
||||
/// — held as a `String` here because pulling the surface error type
|
||||
/// into core would create a circular crate dependency.
|
||||
#[error("surface parse error in {path}: {message}")]
|
||||
SurfaceParse {
|
||||
path: std::path::PathBuf,
|
||||
message: String,
|
||||
},
|
||||
|
||||
/// An `import { module: "foo" }` did not resolve to an existing
|
||||
/// file at the expected path.
|
||||
#[error("module `{name}` not found (expected at {expected_path})")]
|
||||
@@ -409,6 +421,30 @@ fn load_prelude() -> Module {
|
||||
/// - `visiting`: stack of modules whose DFS descent is still running.
|
||||
/// A hit here = cycle.
|
||||
pub fn load_workspace(entry_path: &Path) -> Result<Workspace, WorkspaceLoadError> {
|
||||
load_workspace_with(entry_path, load_one)
|
||||
}
|
||||
|
||||
/// Load a workspace using a caller-supplied per-module loader.
|
||||
///
|
||||
/// The standard entry point [`load_workspace`] is a thin wrapper that
|
||||
/// passes [`load_one`] as the loader and so only accepts `.ail.json`
|
||||
/// files. Pass a different loader (for example, an extension-dispatching
|
||||
/// loader from `ailang-surface`) to accept `.ail` source files as well.
|
||||
///
|
||||
/// The loader is invoked for the entry module and for every transitive
|
||||
/// import. It is responsible for reading bytes, parsing, and returning
|
||||
/// the in-memory [`Module`].
|
||||
///
|
||||
/// ext-cli.1: introduced so `ailang-surface::load_workspace` can plug
|
||||
/// in an extension-dispatching loader without `ailang-core` importing
|
||||
/// `ailang-surface` (which would close a crate cycle).
|
||||
pub fn load_workspace_with<F>(
|
||||
entry_path: &Path,
|
||||
loader: F,
|
||||
) -> Result<Workspace, WorkspaceLoadError>
|
||||
where
|
||||
F: Fn(&Path) -> Result<Module, WorkspaceLoadError> + Copy,
|
||||
{
|
||||
let entry_path = entry_path.to_path_buf();
|
||||
let root_dir = entry_path
|
||||
.parent()
|
||||
@@ -416,7 +452,7 @@ pub fn load_workspace(entry_path: &Path) -> Result<Workspace, WorkspaceLoadError
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
|
||||
// Load entry module + check name<->filename convention.
|
||||
let entry_module = load_one(&entry_path)?;
|
||||
let entry_module = loader(&entry_path)?;
|
||||
let expected_entry_name = module_name_from_path(&entry_path);
|
||||
if entry_module.name != expected_entry_name {
|
||||
return Err(WorkspaceLoadError::ModuleNameMismatch {
|
||||
@@ -437,6 +473,7 @@ pub fn load_workspace(entry_path: &Path) -> Result<Workspace, WorkspaceLoadError
|
||||
&mut modules,
|
||||
&mut visiting,
|
||||
&mut visiting_set,
|
||||
loader,
|
||||
)?;
|
||||
|
||||
// Iter 23.1: inject the prelude module. It is implicitly
|
||||
@@ -1350,13 +1387,17 @@ fn type_head_name(t: &Type) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn visit(
|
||||
fn visit<F>(
|
||||
module: Module,
|
||||
root_dir: &Path,
|
||||
modules: &mut BTreeMap<String, Module>,
|
||||
visiting: &mut Vec<String>,
|
||||
visiting_set: &mut HashSet<String>,
|
||||
) -> Result<(), WorkspaceLoadError> {
|
||||
loader: F,
|
||||
) -> Result<(), WorkspaceLoadError>
|
||||
where
|
||||
F: Fn(&Path) -> Result<Module, WorkspaceLoadError> + Copy,
|
||||
{
|
||||
let name = module.name.clone();
|
||||
|
||||
// Already fully loaded? Then do nothing. Hash consistency is checked
|
||||
@@ -1378,12 +1419,23 @@ fn visit(
|
||||
// Process imports recursively.
|
||||
let imports = module.imports.clone();
|
||||
for imp in &imports {
|
||||
let imp_path = root_dir.join(format!("{}.ail.json", imp.module));
|
||||
// ext-cli.1: prefer a sibling `<module>.ail` (Form A) over
|
||||
// `<module>.ail.json` (Form B). The injected `loader` is
|
||||
// responsible for reading whichever extension wins.
|
||||
let imp_path = {
|
||||
let surface_path = root_dir.join(format!("{}.ail", imp.module));
|
||||
let json_path = root_dir.join(format!("{}.ail.json", imp.module));
|
||||
if surface_path.is_file() {
|
||||
surface_path
|
||||
} else {
|
||||
json_path
|
||||
}
|
||||
};
|
||||
|
||||
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) {
|
||||
let on_disk = match loader(&imp_path) {
|
||||
Ok(m) => m,
|
||||
Err(WorkspaceLoadError::Io { .. }) => continue,
|
||||
Err(e) => return Err(e),
|
||||
@@ -1409,14 +1461,14 @@ fn visit(
|
||||
});
|
||||
}
|
||||
|
||||
let imported = load_one(&imp_path)?;
|
||||
let imported = loader(&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)?;
|
||||
visit(imported, root_dir, modules, visiting, visiting_set, loader)?;
|
||||
}
|
||||
|
||||
visiting.pop();
|
||||
@@ -2608,4 +2660,40 @@ mod tests {
|
||||
other => panic!("expected QualifiedClassName, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// ext-cli.1 Task 1: the new `load_workspace_with` injection point
|
||||
/// invokes the caller-supplied loader exactly once for the entry
|
||||
/// module of a single-module workspace. Protects against the
|
||||
/// extension-dispatching loader (in `ailang-surface`) silently
|
||||
/// falling back to `load_one` and so never being given a `.ail`
|
||||
/// path to read.
|
||||
#[test]
|
||||
fn load_workspace_with_custom_loader_is_called() {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
let tmpdir = tempfile::tempdir().expect("tempdir");
|
||||
let entry = tmpdir.path().join("dummy.ail.json");
|
||||
std::fs::write(&entry, fixture_module_json("dummy")).expect("write");
|
||||
|
||||
static CALLS: AtomicUsize = AtomicUsize::new(0);
|
||||
CALLS.store(0, Ordering::SeqCst);
|
||||
|
||||
let loader = |path: &std::path::Path| -> Result<Module, WorkspaceLoadError> {
|
||||
CALLS.fetch_add(1, Ordering::SeqCst);
|
||||
load_one(path)
|
||||
};
|
||||
|
||||
let ws = load_workspace_with(&entry, loader)
|
||||
.expect("workspace loads via custom loader");
|
||||
assert_eq!(ws.entry, "dummy");
|
||||
assert_eq!(
|
||||
CALLS.load(Ordering::SeqCst),
|
||||
1,
|
||||
"custom loader called once for single-module workspace"
|
||||
);
|
||||
}
|
||||
|
||||
fn fixture_module_json(name: &str) -> String {
|
||||
format!(r#"{{"schema":"ailang/v0","name":"{name}","imports":[],"defs":[]}}"#)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user