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:
2026-05-12 14:45:03 +02:00
parent efecbaa3e6
commit 79cc78507b
15 changed files with 611 additions and 35 deletions
+3
View File
@@ -8,3 +8,6 @@ license.workspace = true
ailang-core.workspace = true
serde_json.workspace = true
thiserror.workspace = true
[dev-dependencies]
tempfile.workspace = true
+2
View File
@@ -32,8 +32,10 @@
//! consume `Module` values regardless of which front-end produced them.
pub mod lex;
pub mod loader;
pub mod parse;
pub mod print;
pub use loader::{load_module, load_workspace};
pub use parse::{parse, parse_term, ParseError};
pub use print::{print, term_to_form_a, type_to_form_a};
+71
View File
@@ -0,0 +1,71 @@
//! Extension-dispatching loader for AILang source files.
//!
//! AILang has two file forms today: Form A (`.ail`, the LLM
//! authoring surface) and Form B (`.ail.json`, the canonical
//! JSON-AST). These loaders accept both: for `.ail` they read the
//! source text and parse it via [`crate::parse`]; for `.ail.json`
//! they delegate to [`ailang_core::load_module`].
//!
//! These functions live in `ailang-surface` rather than
//! `ailang-core` because `ailang-core` cannot import `ailang-surface`
//! without creating a circular crate dependency. The cross-crate
//! injection point is [`ailang_core::workspace::load_workspace_with`],
//! introduced in iter ext-cli.1.
use ailang_core::ast::Module;
use ailang_core::workspace::{load_workspace_with, Workspace, WorkspaceLoadError};
use std::path::Path;
fn is_ail_source(path: &Path) -> bool {
path.extension().and_then(|s| s.to_str()) == Some("ail")
}
/// Load a single module from either `.ail` or `.ail.json`.
///
/// Dispatches on file extension:
///
/// - `.ail` — read source text, parse via [`crate::parse`], return
/// the in-memory [`Module`].
/// - anything else — delegate to [`ailang_core::load_module`] and
/// convert its `Error` into the corresponding
/// [`WorkspaceLoadError`] variant.
///
/// Errors are returned as [`WorkspaceLoadError`] so that single-
/// module callers and workspace callers can share the same error
/// type (and `workspace_error_to_diagnostic` in the binary can
/// route them through one channel).
pub fn load_module(path: &Path) -> Result<Module, WorkspaceLoadError> {
if is_ail_source(path) {
let src = std::fs::read_to_string(path).map_err(|e| WorkspaceLoadError::Io {
path: path.to_path_buf(),
source: e,
})?;
crate::parse(&src).map_err(|e| WorkspaceLoadError::SurfaceParse {
path: path.to_path_buf(),
message: format!("{e}"),
})
} else {
match ailang_core::load_module(path) {
Ok(m) => Ok(m),
Err(ailang_core::Error::Io(e)) => Err(WorkspaceLoadError::Io {
path: path.to_path_buf(),
source: e,
}),
Err(e) => Err(WorkspaceLoadError::Schema {
path: path.to_path_buf(),
source: e,
}),
}
}
}
/// Load a workspace from an entry path, accepting either `.ail` or
/// `.ail.json` for the entry and for every transitive import.
///
/// Import resolution prefers a sibling `<module>.ail` over
/// `<module>.ail.json`; this is the new precedence rule baked into
/// `core::workspace::visit`. Mixed-extension workspaces (entry is
/// `.ail`, some imports are `.ail.json`) are valid.
pub fn load_workspace(entry: &Path) -> Result<Workspace, WorkspaceLoadError> {
load_workspace_with(entry, load_module)
}
+118
View File
@@ -0,0 +1,118 @@
//! Surface-side loader: dispatches by file extension.
//!
//! `.ail` → `surface::parse` (Form A); `.ail.json` → `core::load_module`
//! (Form B). Workspace import resolution prefers `.ail` over `.ail.json`.
use ailang_surface::{load_module, load_workspace};
use std::fs;
/// Property: `load_module` on a `.ail` (Form A) source file dispatches
/// to `ailang_surface::parse` and returns the parsed module. Guards
/// against the dispatch silently falling through to the JSON loader
/// (which would fail on Form-A bytes with the misleading
/// `json: expected value at line 1 column 1` error the rest of this
/// iter exists to eliminate).
#[test]
fn load_module_dispatches_ail_to_surface_parse() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("hello.ail");
// Smallest valid Form-A module: header + no defs.
fs::write(&path, "(module hello)\n").expect("write");
let module = load_module(&path).expect("surface parses .ail");
assert_eq!(module.name, "hello");
}
/// Property: `load_module` on a `.ail.json` (Form B) source file
/// delegates to `ailang_core::load_module` and produces the same
/// `Module` value the core entry point would. Guards against the
/// surface loader silently shadowing or rewriting the JSON path.
#[test]
fn load_module_dispatches_ail_json_to_core_loader() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("dummy.ail.json");
fs::write(
&path,
r#"{"schema":"ailang/v0","name":"dummy","imports":[],"defs":[]}"#,
)
.expect("write");
let surface_loaded = load_module(&path).expect("surface dispatches .ail.json to core");
let core_loaded = ailang_core::load_module(&path).expect("core loads directly");
assert_eq!(surface_loaded.name, core_loaded.name);
assert_eq!(surface_loaded.schema, core_loaded.schema);
}
/// Property: workspace import resolution accepts mixed extensions —
/// a `.ail` entry module can import a sibling provided as `.ail.json`,
/// and both modules end up in the loaded workspace. Guards against
/// the `.ail`-first / `.ail.json`-fallback precedence rule in
/// `workspace::visit` regressing back to JSON-only resolution.
#[test]
fn load_workspace_resolves_ail_imports_first_then_ail_json() {
let tmp = tempfile::tempdir().expect("tempdir");
let entry = tmp.path().join("main.ail");
// Surface grammar uses one `(import <name>)` clause per import.
fs::write(&entry, "(module main (import leaf))\n").expect("write entry");
let leaf = tmp.path().join("leaf.ail.json");
fs::write(
&leaf,
r#"{"schema":"ailang/v0","name":"leaf","imports":[],"defs":[]}"#,
)
.expect("write leaf");
let ws = load_workspace(&entry).expect("workspace loads with mixed extensions");
assert!(ws.modules.contains_key("main"));
assert!(ws.modules.contains_key("leaf"));
}
/// Property: when BOTH `<module>.ail` (Form A) and `<module>.ail.json`
/// (Form B) exist in the workspace root, the `.ail` sibling wins —
/// the loaded module's content reflects Form A, not Form B. Pins the
/// `surface_path.is_file()` precedence branch in
/// `workspace::visit`'s import-path construction. Regression would
/// look like the loader silently shadowing a freshly-edited `.ail`
/// with a stale `.ail.json` snapshot of the same module name.
#[test]
fn load_workspace_prefers_ail_when_both_extensions_exist() {
let tmp = tempfile::tempdir().expect("tempdir");
// Entry imports `leaf`.
let entry = tmp.path().join("main.ail");
fs::write(&entry, "(module main (import leaf))\n").expect("write entry");
// Both leaf.ail and leaf.ail.json exist, with different in-source content.
// The `.ail` carries a `(const winner ...)`; the `.ail.json` carries a
// `(const loser ...)`. The loader must pick `winner`.
let leaf_ail = tmp.path().join("leaf.ail");
fs::write(
&leaf_ail,
"(module leaf (const winner (type (con Int)) (body 1)))\n",
)
.expect("write leaf.ail");
let leaf_json = tmp.path().join("leaf.ail.json");
fs::write(
&leaf_json,
r#"{"schema":"ailang/v0","name":"leaf","imports":[],"defs":[{"kind":"const","name":"loser","type":{"k":"con","name":"Int"},"value":{"t":"lit","lit":{"kind":"int","value":2}}}]}"#,
)
.expect("write leaf.ail.json");
let ws = load_workspace(&entry).expect("workspace loads with both extensions present");
let leaf = &ws.modules["leaf"];
let names: Vec<&str> = leaf
.defs
.iter()
.map(|d| ailang_core::def_name(d))
.collect();
assert!(
names.contains(&"winner"),
"expected `.ail` content (def `winner`); got defs: {names:?}"
);
assert!(
!names.contains(&"loser"),
"must NOT have loaded `.ail.json` content (def `loser`); got defs: {names:?}"
);
}