//! `ail` — CLI for AILang. //! //! The user-facing toolchain binary. Wraps the `ailang-core` //! (loader/AST/hashing), `ailang-check` (typechecker), and //! `ailang-codegen` (LLVM IR emitter) crates into a set of //! deliberately small subcommands. The slicing follows the project's //! LLM-author audience: each individual tool gives the LLM the //! minimum context needed for one task — `manifest` for an overview, //! `describe` for a single def, `emit-ir` for the exact machine view, //! `build` for full-pipeline validation. No "do everything" command. //! //! # Subcommands //! //! - `manifest` — compact symbol table of a module (or workspace); //! one line per def with type, effects, hash. //! - `render` — print a `.ail.json` module as form-(A) text. Exact //! inverse of `parse`; piping `render | parse` round-trips to the //! same canonical bytes. //! - `describe` — full detail of a single definition, JSON or text. //! - `deps` — static call edges per def (or for one named def); //! workspace mode emits cross-module edges. //! - `check` — load + schema-validate + typecheck. Emits structured //! diagnostics with `--json`; exit code 1 on any error. //! - `emit-ir` — write LLVM IR (`.ll`) for the module. Useful for //! inspecting the generated code without invoking `clang`. //! - `build` — full pipeline (`check` → `emit-ir` → `clang`) producing //! a native binary at `--out`. //! - `run` — `build` into a tempdir and execute the binary; passes //! the binary's exit code through. //! - `builtins` — list the built-in effect ops (`io/print_int`, //! `io/print_bool`, `io/print_str`, ...) with their signatures. //! - `diff` — semantic, hash-based module/workspace diff. Works even //! when a module doesn't currently typecheck. //! - `workspace` — load an entry module's transitive imports and list //! the reachable modules with hash and def count. //! //! # External tooling //! //! `build` and `run` shell out to `clang` to link the emitted IR into //! a native binary. `clang` must therefore be on `PATH` for those //! subcommands; the other subcommands have no external dependencies. //! //! # rustdoc surface //! //! This is a binary crate with no `pub` items. The user-facing CLI //! help is generated by `clap` from the `#[command(...)]` doc //! comments on `Cmd` variants and surfaces as `ail --help`, not as //! rustdoc. Private helpers are intentionally undocumented at the //! rustdoc level — read the source. use anyhow::{Context, Result}; use clap::{Parser, Subcommand}; use std::path::{Path, PathBuf}; #[derive(Parser)] #[command(name = "ail", version, about = "AILang toolchain")] struct Cli { #[command(subcommand)] cmd: Cmd, } #[derive(Subcommand)] enum Cmd { /// Loads a module and prints a compact symbol table. Manifest { path: PathBuf, #[arg(long)] json: bool, /// Recursively loads all modules of the workspace and lists their /// defs together. Default mode stays single-module. #[arg(long)] workspace: bool, }, /// Prints the module as form (A) text — the canonical authoring /// surface and the exact inverse of `parse`. Round-tripping /// `render | parse` reproduces the input's canonical bytes /// (gated by `crates/ailang-surface/tests/round_trip.rs`). Render { path: PathBuf }, /// Prints a single definition as JSON or pretty text. Describe { path: PathBuf, name: String, #[arg(long)] json: bool, /// Loads the workspace and searches in all modules. /// `name` may be given in dot notation (`.`); /// without a dot: entry module first, then fallback to all modules /// (error `ambiguous-name` if ambiguous). #[arg(long)] workspace: bool, }, /// Lists which symbols each definition calls (statically). Deps { path: PathBuf, /// Only for one symbol; without argument: for all. In workspace /// mode the name may be in dot notation (`.`). #[arg(long)] of: Option, #[arg(long)] json: bool, /// Workspace mode: edges are cross-module aware /// (`. -> .`). #[arg(long)] workspace: bool, }, /// Typechecks a module. Check { path: PathBuf, /// Structured diagnostics as a JSON array on stdout. /// Exit code 1 when at least one error is reported. #[arg(long)] json: bool, }, /// Writes LLVM IR (.ll) for the module. EmitIr { path: PathBuf, #[arg(short, long)] out: Option, }, /// Full pipeline: check + emit-ir + clang -> binary. Build { path: PathBuf, #[arg(short, long)] out: Option, /// Optimization (e.g. `-O2`); default `-O0` for debuggability. #[arg(long, default_value = "-O0")] opt: String, /// Heap allocator. `rc` (default, canonical) routes through /// `runtime/rc.c`'s `@ailang_rc_alloc` (libc-malloc backing + /// 8-byte refcount header) with `ailang_rc_inc`/`_dec` /// instrumentation emitted by codegen; this is the runtime /// AILang's memory model (RC + uniqueness, Decision 10) is /// designed for. `gc` retains the Boehm conservative GC path /// (`@GC_malloc`, libgc); it is kept as a differential parity /// oracle for codegen diagnosis (Decision 9). `bump` is a /// bench-only no-free stub from `runtime/bump.c` — it leaks /// every allocation by design. #[arg(long, default_value = "rc", value_parser = ["gc", "bump", "rc"])] alloc: String, }, /// Build into a tempdir and execute. Exits with the binary's exit code. /// Convenience wrapper around `build` + invocation of the resulting /// binary; useful in iteration loops where we don't care about the /// output artefact's location. Run { path: PathBuf, /// Optimization (e.g. `-O2`); default `-O0` for debuggability. #[arg(long, default_value = "-O0")] opt: String, /// Heap allocator. See `build --alloc` for details. Default `rc`. #[arg(long, default_value = "rc", value_parser = ["gc", "bump", "rc"])] alloc: String, /// Args passed through to the compiled program. #[arg(last = true)] args: Vec, }, /// Lists built-in operations with their signatures. Builtins { #[arg(long)] json: bool, }, /// Semantic module diff via def hash. /// /// Compares two modules purely structurally on top-level defs: /// per name, the hashes of canonical bytes are compared. The /// diff works even when a module doesn't currently typecheck — /// only the schema and the JSON form must be loadable. /// /// Exit code: 0 if no changes (other than `unchanged`), otherwise 1. Diff { a: PathBuf, b: PathBuf, #[arg(long)] json: bool, /// Compares two workspaces (entry modules + transitive imports) /// module by module. `added_modules`/`removed_modules` for fully /// added or removed modules, `changed_modules` for modules with /// a different hash; per changed module, the usual /// single-module sub-diff structure. #[arg(long)] workspace: bool, }, /// Loads a workspace (entry module + transitive imports) and lists /// all reachable modules with hash and def count. /// /// Iter 5a: listing only. Cross-module typecheck/codegen follow in /// 5b/5c; existing subcommands continue to work per single module. Workspace { entry: PathBuf, #[arg(long)] json: bool, }, /// Parses a `.ailx` source file (form (A)) into canonical /// `.ail.json`. Iter 14c addition; symmetric to `render`. /// /// The form-(A) projection is one of potentially many producers of /// `Module` values. The JSON-AST remains the source of truth and /// is what every other subcommand consumes. Parse { path: PathBuf, #[arg(short, long)] output: Option, }, /// Iter 20a: prints the module as human-readable prose (form B). /// /// One-way projection: the renderer is deterministic, but no /// parser exists for the prose surface. The canonical authoring /// surface remains form (A) (`render` / `parse`) and the canonical /// hashable artefact remains the JSON-AST. Prose { path: PathBuf }, /// Iter 20d: composes a prompt for the prose-edit round-trip. /// /// Given the original `.ail.json` and the edited `.prose.txt`, /// prints a prompt that asks an external LLM to emit an updated /// `.ail.json` integrating the prose edits. AILang ships no LLM /// client of its own; the user pipes the prompt to their tool of /// choice (Claude Code, Anthropic API, OpenAI CLI, etc.) and runs /// `ail check` on the result. /// /// See `docs/PROSE_ROUNDTRIP.md` for the full cycle and the /// rationale for keeping the API client out of scope. MergeProse { /// Original `.ail.json` (carries load-bearing detail the prose elides). original: PathBuf, /// Edited `.prose.txt` (carries the human's intent). edited: PathBuf, }, } /// Composes the prose-round-trip prompt described in /// `docs/PROSE_ROUNDTRIP.md`. /// /// The three payloads — the AILang Form-A specification, the original /// module rendered as Form-A, and the edited prose — are inserted /// verbatim between heredoc-style markers. The rest of the returned /// string is the role-statement, contract, and output spec that frame /// the LLM's task. /// /// Iter 20f rewrote this function: the prompt now embeds /// [`ailang_core::FORM_A_SPEC`] (the language reference an LLM needs /// to generate AILang from scratch) and the original module as Form-A /// (the canonical authoring surface, Decision 6) instead of JSON-AST. /// JSON-AST stays the canonical hashable artefact, but no human or /// LLM should write it directly — `ail parse` converts the LLM's /// Form-A output to JSON. /// /// Pure: same inputs always yield the same bytes. The CLI wrapper /// (`Cmd::MergeProse`) is just file-reading + Form-A render + `print!`. fn compose_merge_prose_prompt(original_form_a: &str, edited_prose: &str) -> String { // Built with String::push_str rather than format!() because // `FORM_A_SPEC` contains literal `{` and `}` from embedded JSON // examples, which would break format!'s placeholder escaping. let mut out = String::new(); out.push_str( "You are integrating prose edits back into an AILang module. ROLE Your job is to produce an updated AILang module in Form-A (an .ailx file) that reflects the human's prose edits while preserving the load-bearing semantic detail from the original module. CONTRACT The prose is the source of intent: the human edited it to express what the program should now do. The original Form-A carries load-bearing detail that the prose surface elides — preserve those details unless the prose explicitly contradicts them. Specifically: - Mode annotations on fn parameters (`(own T)`, `(borrow T)`). These are hard contracts (memory model). The prose shows them as `own T` / `borrow T`; the Form-A wraps them. If the prose is ambiguous, default to the original. - Effect annotations on return types (`(effects IO)`, `(effects Diverge)`). Prose shows these as `with IO` etc.; if uncertain, keep what the original had. - `tail` flags on calls. The prose prints `tail f(x)`; the Form-A keyword is `(tail-app f x)`. If the edit moved a call, decide whether the new position is still in tail position. - Doc strings (`(doc \"...\")` clauses; prose shows them as `///`). - `(suppress (code ...) (because ...))` clauses (prose shows them as `// @suppress code: reason`). The `because` MUST be non-empty. - Constructor names and arities (the prose `Cons(h, t)` must round-trip to `(term-ctor TYPE Cons h t)` with the right TYPE). OUTPUT Output ONLY the new Form-A bytes. No commentary, no markdown fences, no preamble or postscript. The output must be parseable by `ail parse` (it will be piped to that command immediately). The first non-whitespace byte should be `(` (the opening of `(module ...)`) and the last non-whitespace byte should be `)`. ", ); out.push_str("FORM-A SPECIFICATION\n"); out.push_str("<< Result<()> { let cli = Cli::parse(); match cli.cmd { Cmd::Manifest { path, json, workspace } => { if workspace { // Workspace mode: load all modules and emit their defs // together, alphabetically by (module, name). let ws = ailang_core::load_workspace(&path)?; let mut entries: Vec<(String, &ailang_core::Def)> = Vec::new(); for (mod_name, m) in &ws.modules { for d in &m.defs { entries.push((mod_name.clone(), d)); } } entries.sort_by(|a, b| { a.0.cmp(&b.0).then_with(|| a.1.name().cmp(b.1.name())) }); if json { let symbols: Vec<_> = entries .iter() .map(|(mod_name, d)| { let (kind, ty, effects) = def_summary(d); serde_json::json!({ "module": mod_name, "name": d.name(), "kind": kind, "type": ty, "effects": effects, "hash": ailang_core::def_hash(d), }) }) .collect(); let out = serde_json::json!({ "workspace": ws.entry, "schema": ailang_core::SCHEMA, "symbols": symbols, }); println!("{}", serde_json::to_string_pretty(&out)?); } else { // Text form: per entry `. :: ![effs] `. let label_width = entries .iter() .map(|(m, d)| m.len() + 1 + d.name().len()) .max() .unwrap_or(0); for (mod_name, d) in &entries { let (_, ty, effects) = def_summary(d); let label = format!("{mod_name}.{}", d.name()); let eff = if effects.is_empty() { String::new() } else { format!(" ![{}]", effects.join(",")) }; println!( "{: = m .defs .iter() .map(|d| { let (kind, ty, effects) = def_summary(d); serde_json::json!({ "name": d.name(), "kind": kind, "type": ty, "effects": effects, "hash": ailang_core::def_hash(d), }) }) .collect(); let out = serde_json::json!({ "module": m.name, "schema": m.schema, "symbols": entries, }); println!("{}", serde_json::to_string_pretty(&out)?); } else { print!("{}", ailang_core::pretty::manifest(&m)); } } } Cmd::Render { path } => { let m = ailang_core::load_module(&path)?; print!("{}", ailang_surface::print(&m)); } Cmd::Prose { path } => { // Iter 20a: load via `load_module` (single-module mode, // matching how `render` / `parse` work). Workspace-wide // prose is not in scope for 20a. let m = ailang_core::load_module(&path)?; print!("{}", ailang_prose::module_to_prose(&m)); } Cmd::MergeProse { original, edited } => { // Iter 20f: load the original .ail.json, render it as // Form-A (the canonical authoring surface, Decision 6), // and embed *that* in the prompt — not the raw JSON-AST. // Form-A is the form an LLM should produce; JSON is the // hashable artefact, not the writing surface. The prose // file is embedded byte-for-byte. let module = ailang_core::load_module(&original) .with_context(|| format!("loading {}", original.display()))?; let original_form_a = ailang_surface::print(&module); let prose = std::fs::read_to_string(&edited) .with_context(|| format!("reading {}", edited.display()))?; print!("{}", compose_merge_prose_prompt(&original_form_a, &prose)); } Cmd::Describe { path, name, json, workspace } => { if workspace { let ws = ailang_core::load_workspace(&path)?; let (mod_name, def) = resolve_describe_name(&ws, &name)?; if json { // We pass the def itself through and add the // module so consumers know the context. let mut v = serde_json::to_value(def)?; if let Some(obj) = v.as_object_mut() { obj.insert( "module".to_string(), serde_json::Value::String(mod_name.clone()), ); obj.insert( "hash".to_string(), serde_json::Value::String(ailang_core::def_hash(def)), ); } println!("{}", serde_json::to_string_pretty(&v)?); } else { let m = ws.modules.get(&mod_name).unwrap(); let one = ailang_core::Module { schema: m.schema.clone(), name: m.name.clone(), imports: vec![], defs: vec![def.clone()], }; let h = ailang_core::def_hash(def); println!("module: {}", mod_name); println!("hash: {h}"); print!("{}", ailang_surface::print(&one)); } } else { let m = ailang_core::load_module(&path)?; let def = m .defs .iter() .find(|d| d.name() == name) .with_context(|| format!("no def `{name}` in module `{}`", m.name))?; if json { let s = serde_json::to_string_pretty(def)?; println!("{s}"); } else { // Form-(A) projection of a one-def module. let one = ailang_core::Module { schema: m.schema.clone(), name: m.name.clone(), imports: vec![], defs: vec![def.clone()], }; let h = ailang_core::def_hash(def); println!("hash: {h}"); print!("{}", ailang_surface::print(&one)); } } } Cmd::Check { path, json } => { // Iter 5b: `ail check` now **always** loads via // `load_workspace` and checks cross-module. For modules // without imports, the workspace loader behaves equivalently // to `load_module` plus a hash consistency check of the // entry file — keeping the path uniform. if json { // JSON mode: stdout contains only the diagnostics // array. Workspace load errors are emitted as structured // diagnostics (codes `module-not-found`, // `module-cycle`, `module-name-mismatch`, `schema-mismatch`). // Real I/O errors on the entry file remain fatal. let diags = match ailang_core::load_workspace(&path) { Ok(ws) => ailang_check::check_workspace(&ws), Err(e) => match workspace_error_to_diagnostic(&e) { Some(d) => vec![d], None => return Err(anyhow::anyhow!(e)), }, }; println!("{}", serde_json::to_string(&diags)?); if diags .iter() .any(|d| matches!(d.severity, ailang_check::Severity::Error)) { std::process::exit(1); } } else { let ws = ailang_core::load_workspace(&path)?; let diags = ailang_check::check_workspace(&ws); if !diags.is_empty() { for d in &diags { eprintln!( "{}: [{}] {}{}", match d.severity { ailang_check::Severity::Error => "error", ailang_check::Severity::Warning => "warning", }, d.code, d.def .as_ref() .map(|n| format!("{n}: ")) .unwrap_or_default(), d.message, ); } // Iter 19a: only Error-severity diagnostics fail the // command. Warning-severity diagnostics (e.g. the // `over-strict-mode` lint) print to stderr but the // module is still considered to have typechecked. if diags .iter() .any(|d| matches!(d.severity, ailang_check::Severity::Error)) { std::process::exit(1); } } let total: usize = ws.modules.values().map(|m| m.defs.len()).sum(); println!( "ok ({} symbols across {} modules)", total, ws.modules.len() ); } } Cmd::EmitIr { path, out } => { // Iter 5c: workspace lowering. For single-module programs the // workspace is effectively a trivial workspace with one module. let ws = ailang_core::load_workspace(&path)?; let diags = ailang_check::check_workspace(&ws); if !diags.is_empty() { for d in &diags { eprintln!( "{}: [{}] {}{}", match d.severity { ailang_check::Severity::Error => "error", ailang_check::Severity::Warning => "warning", }, d.code, d.def .as_ref() .map(|n| format!("{n}: ")) .unwrap_or_default(), d.message, ); } // Iter 19a: only Error-severity blocks codegen. if diags .iter() .any(|d| matches!(d.severity, ailang_check::Severity::Error)) { std::process::exit(1); } } let ir = ailang_codegen::lower_workspace(&ws)?; match out { Some(p) => { std::fs::write(&p, ir)?; eprintln!("wrote {}", p.display()); } None => print!("{ir}"), } } Cmd::Build { path, out, opt, alloc } => { let strategy = parse_alloc_strategy(&alloc)?; let bin = build_to(&path, out, &opt, strategy)?; eprintln!("built {}", bin.display()); } Cmd::Run { path, opt, alloc, args } => { // Iter 9b: build into a fresh tempdir per run, exec, propagate // exit code. The artefact dir is left around (no cleanup) so // it can be inspected in case of a crash; OS temp policy // collects them. let strategy = parse_alloc_strategy(&alloc)?; let tmpdir = std::env::temp_dir().join(format!( "ailang-run-{}", std::process::id() )); std::fs::create_dir_all(&tmpdir)?; let bin = build_to(&path, Some(tmpdir.join("bin")), &opt, strategy)?; let status = std::process::Command::new(&bin) .args(&args) .status() .with_context(|| format!("executing {}", bin.display()))?; std::process::exit(status.code().unwrap_or(127)); } Cmd::Builtins { json } => { let list = ailang_check::builtins::list(); if json { let arr: Vec<_> = list .iter() .map(|(n, s)| serde_json::json!({ "name": n, "sig": s })) .collect(); println!("{}", serde_json::to_string_pretty(&arr)?); } else { for (n, sig) in list { println!("{n:<16} {sig}"); } } } Cmd::Diff { a, b, json, workspace } => { if workspace { let ws_a = ailang_core::load_workspace(&a)?; let ws_b = ailang_core::load_workspace(&b)?; let report = build_workspace_diff(&ws_a, &ws_b); if json { let v = workspace_diff_report_to_json(&report); println!("{}", serde_json::to_string_pretty(&v)?); } else { print!("{}", render_workspace_diff_text(&report)); } if !report.is_identical() { std::process::exit(1); } } else { let ma = ailang_core::load_module(&a)?; let mb = ailang_core::load_module(&b)?; let report = build_diff(&ma, &mb); if json { let v = diff_report_to_json(&report); println!("{}", serde_json::to_string_pretty(&v)?); } else { print!("{}", render_diff_text(&report)); } if !report.is_identical() { std::process::exit(1); } } } Cmd::Workspace { entry, json } => { let ws = ailang_core::load_workspace(&entry)?; // Iterate alphabetically over module names (BTreeMap order // is already sorted; assert it explicitly). 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 { // Align column width to the longest module name. The // first column marks the entry module with `*`. 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} {:3} defs", name, hash, defs, width = name_width, ); } } } Cmd::Parse { path, output } => { // Read .ailx, parse via the surface crate, emit canonical // JSON. Symmetric to `render` (which goes the other way). let src = std::fs::read_to_string(&path) .with_context(|| format!("reading {}", path.display()))?; let module = match ailang_surface::parse(&src) { Ok(m) => m, Err(e) => { eprintln!("parse error: {e}"); std::process::exit(1); } }; let bytes = ailang_core::canonical::to_bytes(&module); match output { Some(p) => { std::fs::write(&p, &bytes)?; eprintln!("wrote {}", p.display()); } None => { use std::io::Write; std::io::stdout().write_all(&bytes)?; println!(); } } } Cmd::Deps { path, of, json, workspace } => { if workspace { let ws = ailang_core::load_workspace(&path)?; // `--of NAME`: optional, accepts dot notation // (`.`) or a bare name (matches in all // modules where the def exists). let of_filter: Option<(Option, String)> = of.as_ref().map(|s| { if let Some(idx) = s.find('.') { let m = s[..idx].to_string(); let d = s[idx + 1..].to_string(); (Some(m), d) } else { (None, s.clone()) } }); // Collect edges: (from_module, from_def, target). // `target` is either `Edge::Def { to_module, to_def }` // or `Edge::Effect(eff/op)`. let mut def_edges: Vec<(String, String, String, String)> = Vec::new(); let mut effect_edges: Vec<(String, String, String)> = Vec::new(); for (mod_name, m) in &ws.modules { // Import map of the module — needed for cross-module resolution. let import_map = build_import_map(m); for d in &m.defs { if let Some((mf, df)) = &of_filter { if d.name() != df { continue; } if let Some(mf) = mf { if mod_name != mf { continue; } } } let refs = collect_refs(d); for r in &refs { // Effect refs are encoded as `effect:/` // (see `walk_term`). if let Some(rest) = r.strip_prefix("effect:") { effect_edges.push(( mod_name.clone(), d.name().to_string(), rest.to_string(), )); continue; } // ctor:* / type:* — no cross-module defs at the // MVP language stage; pass through as an opaque // marker with empty to_module. if r.starts_with("ctor:") || r.starts_with("type:") { def_edges.push(( mod_name.clone(), d.name().to_string(), String::new(), r.clone(), )); continue; } // Var ref: can be local or qualified (`pre.def`). if let Some(idx) = r.find('.') { let pre = &r[..idx]; let to_def = &r[idx + 1..]; let to_module = import_map .get(pre) .cloned() .unwrap_or_else(|| pre.to_string()); def_edges.push(( mod_name.clone(), d.name().to_string(), to_module, to_def.to_string(), )); } else { // Local reference — stays in the same module. def_edges.push(( mod_name.clone(), d.name().to_string(), mod_name.clone(), r.clone(), )); } } } } def_edges.sort(); effect_edges.sort(); if json { let mut edges_json: Vec = Vec::new(); for (fm, fd, tm, td) in &def_edges { edges_json.push(serde_json::json!({ "from_module": fm, "from_def": fd, "to_module": tm, "to_def": td, })); } for (fm, fd, eff) in &effect_edges { edges_json.push(serde_json::json!({ "from_module": fm, "from_def": fd, "effect": eff, })); } let out = serde_json::json!({ "workspace": ws.entry, "edges": edges_json, }); println!("{}", serde_json::to_string_pretty(&out)?); } else { for (fm, fd, tm, td) in &def_edges { if tm.is_empty() { println!("{fm}.{fd} -> {td}"); } else { println!("{fm}.{fd} -> {tm}.{td}"); } } for (fm, fd, eff) in &effect_edges { println!("{fm}.{fd} -> effect:{eff}"); } } } else { let m = ailang_core::load_module(&path)?; let mut entries = Vec::new(); for d in &m.defs { if let Some(filter) = &of { if d.name() != filter { continue; } } let mut refs: Vec = collect_refs(d).into_iter().collect(); refs.sort(); entries.push((d.name().to_string(), refs)); } if json { let arr: Vec<_> = entries .iter() .map(|(n, r)| serde_json::json!({ "name": n, "refs": r })) .collect(); println!("{}", serde_json::to_string_pretty(&arr)?); } else { for (n, refs) in entries { if refs.is_empty() { println!("{n:>20} -"); } else { println!("{n:>20} -> {}", refs.join(", ")); } } } } } } Ok(()) } /// Converts a `WorkspaceLoadError` into a suitable diagnostic for the /// JSON mode of `ail check`. Pure I/O errors have no module diagnostic /// equivalent (they aren't the pipeline's concern for a consumer); for /// those we return `None` and let the caller fail fatally. fn workspace_error_to_diagnostic( e: &ailang_core::WorkspaceLoadError, ) -> Option { use ailang_core::WorkspaceLoadError as W; match e { W::Io { .. } => None, W::Schema { source, .. } => match source { ailang_core::Error::SchemaMismatch { expected, got } => Some( ailang_check::Diagnostic::error( "schema-mismatch", format!( "schema mismatch: expected {expected:?}, got {got:?}" ), ) .with_ctx(serde_json::json!({ "expected": expected, "actual": got, })), ), _ => None, }, W::ModuleNotFound { name, expected_path } => Some( ailang_check::Diagnostic::error( "module-not-found", format!( "module `{name}` not found (expected at {})", expected_path.display() ), ) .with_ctx(serde_json::json!({ "module": name, "expected_path": expected_path.display().to_string(), })), ), W::ModuleNameMismatch { name_in_file, name_from_path, } => Some( ailang_check::Diagnostic::error( "module-name-mismatch", format!( "module name mismatch: file says {name_in_file:?}, path implies {name_from_path:?}" ), ) .with_ctx(serde_json::json!({ "name_in_file": name_in_file, "name_from_path": name_from_path, })), ), W::Cycle { path } => Some( ailang_check::Diagnostic::error( "module-cycle", format!("import cycle: {}", path.join(" -> ")), ) .with_ctx(serde_json::json!({ "path": path, })), ), W::ModuleHashMismatch { name } => Some( ailang_check::Diagnostic::error( "module-hash-mismatch", format!("module `{name}` loaded twice with differing content"), ) .with_ctx(serde_json::json!({ "module": name, })), ), // Iter 22b.1: typeclass-coherence diagnostics emitted from // `workspace::build_registry`. The codes follow the JOURNAL // queue's wording and Decision 11 §"Diagnostic categories". W::OrphanInstance { class, type_repr, defining_module, class_module, type_module, } => Some( ailang_check::Diagnostic::error( "orphan-instance", format!( "orphan instance: `instance {class} {type_repr}` declared in `{defining_module}`, \ but neither `{class}` (in `{class_module}`) nor `{type_repr}` (in `{type_module}`) lives there" ), ) .with_ctx(serde_json::json!({ "class": class, "type": type_repr, "defining_module": defining_module, "class_module": class_module, "type_module": type_module, })), ), W::DuplicateInstance { class, type_repr, first_module, second_module, } => Some( ailang_check::Diagnostic::error( "duplicate-instance", format!( "duplicate instance: `instance {class} {type_repr}` declared in both `{first_module}` and `{second_module}`" ), ) .with_ctx(serde_json::json!({ "class": class, "type": type_repr, "first_module": first_module, "second_module": second_module, })), ), W::MissingMethod { class, type_repr, method, } => Some( ailang_check::Diagnostic::error( "missing-method", format!( "instance `{class} {type_repr}` is missing a body for method `{method}` (no default)" ), ) .with_ctx(serde_json::json!({ "class": class, "type": type_repr, "method": method, })), ), W::OverridingNonExistentMethod { class, type_repr, method, } => Some( ailang_check::Diagnostic::error( "overriding-non-existent-method", format!( "instance `{class} {type_repr}` provides body for method `{method}`, but class `{class}` does not declare it" ), ) .with_ctx(serde_json::json!({ "class": class, "type": type_repr, "method": method, })), ), W::KindMismatch { class, param, method, defining_module, } => Some( ailang_check::Diagnostic::error( "kind-mismatch", format!( "kind mismatch in class `{class}`: parameter `{param}` is used in applied position \ inside method `{method}` (Decision 11 axis 5: class params are kind `*` only)" ), ) .with_ctx(serde_json::json!({ "class": class, "param": param, "method": method, "defining_module": defining_module, })), ), W::InvalidSuperclassParam { class, superclass, expected_param, got_type, } => Some( ailang_check::Diagnostic::error( "invalid-superclass-param", format!( "class `{class}` declares superclass `{superclass} {got_type}`, but its own parameter is `{expected_param}` — superclass `type` must equal class `param`" ), ) .with_ctx(serde_json::json!({ "class": class, "superclass": superclass, "expected_param": expected_param, "got_type": got_type, })), ), W::UnboundConstraintTypeVar { class, method, constraint_class, var, } => Some( ailang_check::Diagnostic::error( "constraint-references-unbound-type-var", format!( "in class `{class}` method `{method}`: constraint `{constraint_class} {var}` references unbound type variable `{var}`" ), ) .with_ctx(serde_json::json!({ "class": class, "method": method, "constraint_class": constraint_class, "var": var, })), ), W::MethodNameCollision { method, kind, first_origin, second_origin, } => Some( ailang_check::Diagnostic::error( "method-name-collision", format!( "method name `{method}` collides ({kind}): defined in `{first_origin}` and `{second_origin}`" ), ) .with_ctx(serde_json::json!({ "method": method, "kind": kind, "first_origin": first_origin, "second_origin": second_origin, })), ), } } /// Collects the *external* references of a definition: everything its body /// depends on that lives outside the def itself. Filtered out: /// /// - **Built-ins** (`+`, `-`, `==`, `not`, …): operators provided by the /// runtime, not the user. The list is owned by `ailang_check::builtins`. /// - **Parameters** of the surrounding `fn`. /// - **Local bindings** introduced by `let` and by pattern variables in /// `match` arms. /// /// Cross-module references (`.`) are always kept — a local /// binding can never shadow a dotted name (the typechecker rejects defs /// with a dot, see DESIGN.md "qualified cross-module references"). fn collect_refs(def: &ailang_core::Def) -> std::collections::BTreeSet { let mut out = std::collections::BTreeSet::new(); let builtins: std::collections::HashSet<&'static str> = ailang_check::builtins::value_names().into_iter().collect(); match def { ailang_core::Def::Fn(f) => { let mut scope: std::collections::HashSet = f.params.iter().cloned().collect(); walk_term(&f.body, &mut out, &builtins, &mut scope); } ailang_core::Def::Const(c) => { let mut scope = std::collections::HashSet::new(); walk_term(&c.value, &mut out, &builtins, &mut scope); } ailang_core::Def::Type(td) => { // A type def references the types of its fields. for c in &td.ctors { for ft in &c.fields { if let ailang_core::Type::Con { name, .. } = ft { out.insert(format!("type:{name}")); } } } } // Iter 22b.1: `ail deps` does not yet trace references inside // class/instance defs. Walking method signatures and bodies // (default bodies, instance method bodies) lands in 22b.2 along // with the typecheck arms — until then a class/instance def // contributes no entries to the dependency graph. Tools that // call `collect_refs` (`ail deps`, the cross-module-fan-out // diagnostics) therefore treat them as leaves. ailang_core::Def::Class(_) | ailang_core::Def::Instance(_) => {} } out } fn walk_term( t: &ailang_core::Term, out: &mut std::collections::BTreeSet, builtins: &std::collections::HashSet<&'static str>, scope: &mut std::collections::HashSet, ) { use ailang_core::Term; match t { Term::Lit { .. } => {} Term::Var { name } => { // Qualified (`prefix.def`) — always external; the typechecker // forbids dots in def names, so no shadowing risk. if name.contains('.') { out.insert(name.clone()); return; } if scope.contains(name) || builtins.contains(name.as_str()) { return; } out.insert(name.clone()); } Term::App { callee, args, .. } => { walk_term(callee, out, builtins, scope); for a in args { walk_term(a, out, builtins, scope); } } Term::Let { name, value, body } => { // `value` is in the outer scope; only `body` sees the binding. walk_term(value, out, builtins, scope); let inserted = scope.insert(name.clone()); walk_term(body, out, builtins, scope); if inserted { scope.remove(name); } } Term::If { cond, then, else_ } => { walk_term(cond, out, builtins, scope); walk_term(then, out, builtins, scope); walk_term(else_, out, builtins, scope); } Term::Do { op, args, .. } => { // Mark effect ops as `effect:io/print_int` so they can be // separated from normal function calls. out.insert(format!("effect:{op}")); for a in args { walk_term(a, out, builtins, scope); } } Term::Ctor { type_name, ctor, args } => { out.insert(format!("ctor:{type_name}/{ctor}")); for a in args { walk_term(a, out, builtins, scope); } } Term::Match { scrutinee, arms } => { walk_term(scrutinee, out, builtins, scope); for arm in arms { if let ailang_core::ast::Pattern::Ctor { ctor, .. } = &arm.pat { out.insert(format!("ctor:{ctor}")); } let bound = bind_pattern(&arm.pat, scope); walk_term(&arm.body, out, builtins, scope); for n in bound { scope.remove(&n); } } } Term::Lam { params, body, .. } => { // Lambda params shadow outer scope inside the body. let mut newly = Vec::new(); for p in params { if scope.insert(p.clone()) { newly.push(p.clone()); } } walk_term(body, out, builtins, scope); for p in newly { scope.remove(&p); } } Term::Seq { lhs, rhs } => { walk_term(lhs, out, builtins, scope); walk_term(rhs, out, builtins, scope); } Term::LetRec { name, params, body, in_term, .. } => { // Iter 16b.1: `deps` walks the on-disk module before the // desugar pass, so a `Term::LetRec` is reachable here. // Treat it like a fn def for dependency purposes: // `name` shadows for both body and in_term; params shadow // inside the body. let inserted_name = scope.insert(name.clone()); let mut newly = Vec::new(); for p in params { if scope.insert(p.clone()) { newly.push(p.clone()); } } walk_term(body, out, builtins, scope); for p in newly { scope.remove(&p); } walk_term(in_term, out, builtins, scope); if inserted_name { scope.remove(name); } } Term::Clone { value } => { // Iter 18c.1: clone is identity for dependency-walk // purposes. The wrapper introduces no new symbols. walk_term(value, out, builtins, scope); } Term::ReuseAs { source, body } => { // Iter 18d.1: walk both children; the wrapper introduces no // new bindings of its own. walk_term(source, out, builtins, scope); walk_term(body, out, builtins, scope); } } } /// Adds the variable bindings introduced by a pattern to `scope` and /// returns the names that were freshly inserted (so the caller can roll /// them back). MVP-restricted: nested ctor patterns only contain /// `Var`/`Wild` — see DESIGN.md. fn bind_pattern( p: &ailang_core::ast::Pattern, scope: &mut std::collections::HashSet, ) -> Vec { use ailang_core::ast::Pattern; let mut added = Vec::new(); match p { Pattern::Wild | Pattern::Lit { .. } => {} Pattern::Var { name } => { if scope.insert(name.clone()) { added.push(name.clone()); } } Pattern::Ctor { fields, .. } => { for sub in fields { added.extend(bind_pattern(sub, scope)); } } } added } // --- ail diff ------------------------------------------------------------- /// Purely structural module diff. Top-level defs are identified by /// `name` and compared via the BLAKE3-16-hex of the canonical bytes. struct DiffReport { module_a: String, module_b: String, added: Vec, removed: Vec, changed: Vec, unchanged: Vec, } struct DiffEntry { name: String, hash: String, kind: &'static str, } struct ChangedEntry { name: String, hash_a: String, hash_b: String, kind_a: &'static str, kind_b: &'static str, } impl DiffReport { fn is_identical(&self) -> bool { self.added.is_empty() && self.removed.is_empty() && self.changed.is_empty() } } fn build_diff(a: &ailang_core::Module, b: &ailang_core::Module) -> DiffReport { let (added, removed, changed, unchanged) = diff_def_lists(&a.defs, &b.defs); DiffReport { module_a: a.name.clone(), module_b: b.name.clone(), added, removed, changed, unchanged, } } /// Pure list-diff computation for two def slices. Used both by the /// single-module diff and — per `changed_module` — by the workspace diff, /// so the 4-category logic lives in only one place. fn diff_def_lists( a_defs: &[ailang_core::Def], b_defs: &[ailang_core::Def], ) -> (Vec, Vec, Vec, Vec) { use std::collections::BTreeMap; let map_a: BTreeMap<&str, &ailang_core::Def> = a_defs .iter() .map(|d| (ailang_core::def_name(d), d)) .collect(); let map_b: BTreeMap<&str, &ailang_core::Def> = b_defs .iter() .map(|d| (ailang_core::def_name(d), d)) .collect(); let mut added = Vec::new(); let mut removed = Vec::new(); let mut changed = Vec::new(); let mut unchanged = Vec::new(); for (name, def_a) in &map_a { let hash_a = ailang_core::def_hash(def_a); let kind_a = ailang_core::def_kind(def_a); match map_b.get(*name) { None => removed.push(DiffEntry { name: (*name).to_string(), hash: hash_a, kind: kind_a, }), Some(def_b) => { let hash_b = ailang_core::def_hash(def_b); let kind_b = ailang_core::def_kind(def_b); if hash_a == hash_b { unchanged.push(DiffEntry { name: (*name).to_string(), hash: hash_a, kind: kind_a, }); } else { changed.push(ChangedEntry { name: (*name).to_string(), hash_a, hash_b, kind_a, kind_b, }); } } } } for (name, def_b) in &map_b { if !map_a.contains_key(*name) { added.push(DiffEntry { name: (*name).to_string(), hash: ailang_core::def_hash(def_b), kind: ailang_core::def_kind(def_b), }); } } added.sort_by(|x, y| x.name.cmp(&y.name)); removed.sort_by(|x, y| x.name.cmp(&y.name)); changed.sort_by(|x, y| x.name.cmp(&y.name)); unchanged.sort_by(|x, y| x.name.cmp(&y.name)); (added, removed, changed, unchanged) } fn diff_report_to_json(r: &DiffReport) -> serde_json::Value { let entry = |e: &DiffEntry| { serde_json::json!({ "name": e.name, "hash": e.hash, "kind": e.kind, }) }; let changed = |c: &ChangedEntry| { serde_json::json!({ "name": c.name, "hash_a": c.hash_a, "hash_b": c.hash_b, "kind_a": c.kind_a, "kind_b": c.kind_b, }) }; serde_json::json!({ "module_a": r.module_a, "module_b": r.module_b, "added": r.added.iter().map(entry).collect::>(), "removed": r.removed.iter().map(entry).collect::>(), "changed": r.changed.iter().map(changed).collect::>(), "unchanged": r.unchanged.iter().map(entry).collect::>(), }) } fn render_diff_text(r: &DiffReport) -> String { use std::fmt::Write; let mut out = String::new(); let _ = writeln!(out, "diff: {} -> {}", r.module_a, r.module_b); if r.is_identical() && r.unchanged.is_empty() { let _ = writeln!(out, "no changes"); return out; } // Uniform column width for the name/kind column so hashes line up // visually. The longest name sets the width. let name_width = r .added .iter() .map(|e| e.name.len()) .chain(r.removed.iter().map(|e| e.name.len())) .chain(r.changed.iter().map(|e| e.name.len())) .chain(r.unchanged.iter().map(|e| e.name.len())) .max() .unwrap_or(0); for e in &r.added { let _ = writeln!( out, "+ {: {}", c.kind_a, c.kind_b) }; let _ = writeln!( out, "~ {: {}", c.name, kind, c.hash_a, c.hash_b, width = name_width ); } for e in &r.unchanged { let _ = writeln!( out, " {: (&'static str, String, Vec) { match d { ailang_core::Def::Fn(f) => { let effects = match &f.ty { ailang_core::Type::Fn { effects, .. } => effects.clone(), _ => vec![], }; ("fn", ailang_core::pretty::type_to_string(&f.ty), effects) } ailang_core::Def::Const(c) => ( "const", ailang_core::pretty::type_to_string(&c.ty), vec![], ), ailang_core::Def::Type(t) => { let s = t .ctors .iter() .map(|c| { if c.fields.is_empty() { c.name.clone() } else { format!( "{}({})", c.name, c.fields .iter() .map(ailang_core::pretty::type_to_string) .collect::>() .join(", ") ) } }) .collect::>() .join(" | "); ("type", s, vec![]) } // Iter 22b.1: a one-line `class C a` / `instance C T` summary // for `ail manifest`. The `effects` slot is empty for both — // class methods carry their own effect annotations on each // method signature, but the class itself does not (it is a // collection of method signatures, not a single function). ailang_core::Def::Class(c) => ( "class", format!("{} {}", c.name, c.param), vec![], ), ailang_core::Def::Instance(i) => ( "instance", format!( "{} {}", i.class, ailang_core::pretty::type_to_string(&i.type_) ), vec![], ), } } /// Resolution for `ail describe --workspace `. /// /// 1. `name` contains exactly one dot → resolve `.` strictly. /// 2. Otherwise search the entry module first; fall back to other modules /// only if nothing there. Multiple hits produce `ambiguous-name`. fn resolve_describe_name<'ws>( ws: &'ws ailang_core::Workspace, name: &str, ) -> Result<(String, &'ws ailang_core::Def)> { if let Some(idx) = name.find('.') { let mod_name = &name[..idx]; let def_name = &name[idx + 1..]; let m = ws.modules.get(mod_name).with_context(|| { format!("no module `{mod_name}` in workspace `{}`", ws.entry) })?; let def = m .defs .iter() .find(|d| d.name() == def_name) .with_context(|| { format!("no def `{def_name}` in module `{mod_name}`") })?; return Ok((mod_name.to_string(), def)); } // Bare name: entry module first. if let Some(entry_mod) = ws.modules.get(&ws.entry) { if let Some(def) = entry_mod.defs.iter().find(|d| d.name() == name) { return Ok((ws.entry.clone(), def)); } } // Fallback: collect all modules; error on ambiguity. let mut hits: Vec<(String, &ailang_core::Def)> = Vec::new(); for (mod_name, m) in &ws.modules { if mod_name == &ws.entry { continue; } for d in &m.defs { if d.name() == name { hits.push((mod_name.clone(), d)); } } } match hits.len() { 0 => Err(anyhow::anyhow!( "no def `{name}` in workspace `{}`", ws.entry )), 1 => Ok(hits.into_iter().next().unwrap()), _ => { let modules: Vec = hits.iter().map(|(m, _)| m.clone()).collect(); Err(anyhow::anyhow!( "[ambiguous-name] def `{name}` exists in multiple modules: {}", modules.join(", ") )) } } } /// Map ` -> ` for a single module. `` is /// the import alias if set, otherwise the module name itself. Module name /// without a dot. fn build_import_map(m: &ailang_core::Module) -> std::collections::BTreeMap { let mut map = std::collections::BTreeMap::new(); for imp in &m.imports { let prefix = imp.alias.clone().unwrap_or_else(|| imp.module.clone()); map.insert(prefix, imp.module.clone()); } map } // --- Workspace diff ------------------------------------------------------- struct WorkspaceDiffReport { workspace_a: String, workspace_b: String, added_modules: Vec, removed_modules: Vec, unchanged_modules: Vec, changed_modules: Vec, } struct ModuleDiffEntry { name: String, hash: String, } struct ChangedModuleEntry { name: String, hash_a: String, hash_b: String, added: Vec, removed: Vec, changed: Vec, unchanged: Vec, } impl WorkspaceDiffReport { fn is_identical(&self) -> bool { self.added_modules.is_empty() && self.removed_modules.is_empty() && self.changed_modules.is_empty() } } fn build_workspace_diff( a: &ailang_core::Workspace, b: &ailang_core::Workspace, ) -> WorkspaceDiffReport { let mut added = Vec::new(); let mut removed = Vec::new(); let mut unchanged = Vec::new(); let mut changed = Vec::new(); for (name, ma) in &a.modules { let hash_a = ailang_core::module_hash(ma); match b.modules.get(name) { None => removed.push(ModuleDiffEntry { name: name.clone(), hash: hash_a, }), Some(mb) => { let hash_b = ailang_core::module_hash(mb); if hash_a == hash_b { unchanged.push(ModuleDiffEntry { name: name.clone(), hash: hash_a, }); } else { let (sub_added, sub_removed, sub_changed, sub_unchanged) = diff_def_lists(&ma.defs, &mb.defs); changed.push(ChangedModuleEntry { name: name.clone(), hash_a, hash_b, added: sub_added, removed: sub_removed, changed: sub_changed, unchanged: sub_unchanged, }); } } } } for (name, mb) in &b.modules { if !a.modules.contains_key(name) { added.push(ModuleDiffEntry { name: name.clone(), hash: ailang_core::module_hash(mb), }); } } added.sort_by(|x, y| x.name.cmp(&y.name)); removed.sort_by(|x, y| x.name.cmp(&y.name)); unchanged.sort_by(|x, y| x.name.cmp(&y.name)); changed.sort_by(|x, y| x.name.cmp(&y.name)); WorkspaceDiffReport { workspace_a: a.entry.clone(), workspace_b: b.entry.clone(), added_modules: added, removed_modules: removed, unchanged_modules: unchanged, changed_modules: changed, } } fn workspace_diff_report_to_json(r: &WorkspaceDiffReport) -> serde_json::Value { let mod_entry = |e: &ModuleDiffEntry| { serde_json::json!({ "name": e.name, "hash": e.hash }) }; let entry = |e: &DiffEntry| { serde_json::json!({ "name": e.name, "hash": e.hash, "kind": e.kind, }) }; let changed_entry = |c: &ChangedEntry| { serde_json::json!({ "name": c.name, "hash_a": c.hash_a, "hash_b": c.hash_b, "kind_a": c.kind_a, "kind_b": c.kind_b, }) }; let changed_mod = |c: &ChangedModuleEntry| { serde_json::json!({ "name": c.name, "hash_a": c.hash_a, "hash_b": c.hash_b, "added": c.added.iter().map(entry).collect::>(), "removed": c.removed.iter().map(entry).collect::>(), "changed": c.changed.iter().map(changed_entry).collect::>(), "unchanged": c.unchanged.iter().map(entry).collect::>(), }) }; serde_json::json!({ "workspace_a": r.workspace_a, "workspace_b": r.workspace_b, "added_modules": r.added_modules.iter().map(mod_entry).collect::>(), "removed_modules": r.removed_modules.iter().map(mod_entry).collect::>(), "unchanged_modules": r.unchanged_modules.iter().map(mod_entry).collect::>(), "changed_modules": r.changed_modules.iter().map(changed_mod).collect::>(), }) } fn render_workspace_diff_text(r: &WorkspaceDiffReport) -> String { use std::fmt::Write; let mut out = String::new(); let _ = writeln!(out, "workspace diff: {} -> {}", r.workspace_a, r.workspace_b); if r.is_identical() && r.unchanged_modules.is_empty() { let _ = writeln!(out, "no changes"); return out; } for m in &r.added_modules { let _ = writeln!(out, "+ module {} {}", m.name, m.hash); } for m in &r.removed_modules { let _ = writeln!(out, "- module {} {}", m.name, m.hash); } for c in &r.changed_modules { let _ = writeln!( out, "~ module {} {} -> {}", c.name, c.hash_a, c.hash_b ); for e in &c.added { let _ = writeln!(out, " + {} ({}) {}", e.name, e.kind, e.hash); } for e in &c.removed { let _ = writeln!(out, " - {} ({}) {}", e.name, e.kind, e.hash); } for ce in &c.changed { let kind = if ce.kind_a == ce.kind_b { ce.kind_a.to_string() } else { format!("{} -> {}", ce.kind_a, ce.kind_b) }; let _ = writeln!( out, " ~ {} ({}) {} -> {}", ce.name, kind, ce.hash_a, ce.hash_b ); } for e in &c.unchanged { let _ = writeln!(out, " {} ({}) {} (unchanged)", e.name, e.kind, e.hash); } } for m in &r.unchanged_modules { let _ = writeln!(out, " module {} {} (unchanged)", m.name, m.hash); } out } fn parse_alloc_strategy(s: &str) -> Result { match s { "gc" => Ok(ailang_codegen::AllocStrategy::Gc), "bump" => Ok(ailang_codegen::AllocStrategy::Bump), "rc" => Ok(ailang_codegen::AllocStrategy::Rc), other => anyhow::bail!( "unknown --alloc value `{other}` (expected `gc`, `bump`, or `rc`)" ), } } /// Locate the workspace-root `runtime/bump.c` file relative to the /// `ail` binary. We search upwards for a directory that contains /// `runtime/bump.c`; that's the bench-only allocator stub. If we /// can't find it, the build aborts with a clear message — the /// `--alloc=bump` path is opt-in, so this only fires when the user /// asked for it. fn locate_bump_runtime() -> Result { // Two anchors we try in order: // 1. the directory containing the running `ail` binary, walked // up to find `runtime/bump.c` (handles `target/release/ail` // and `target/debug/ail` cleanly). // 2. the current working directory, walked up. let candidates = [ std::env::current_exe().ok(), std::env::current_dir().ok(), ]; for start in candidates.iter().flatten() { let mut cur: &Path = start.as_path(); loop { let candidate = cur.join("runtime").join("bump.c"); if candidate.exists() { return Ok(candidate); } match cur.parent() { Some(p) => cur = p, None => break, } } } anyhow::bail!( "could not locate `runtime/bump.c` (required for --alloc=bump). \ Run `ail` from inside the AILang workspace." ) } /// Locate the workspace-root `runtime/rc.c` file relative to the /// `ail` binary, mirroring [`locate_bump_runtime`]. The `--alloc=rc` /// path (Iter 18b) compiles this stub and links it instead of `-lgc`; /// it supplies `ailang_rc_alloc` / `ailang_rc_inc` / `ailang_rc_dec` /// against libc malloc/free. fn locate_rc_runtime() -> Result { let candidates = [ std::env::current_exe().ok(), std::env::current_dir().ok(), ]; for start in candidates.iter().flatten() { let mut cur: &Path = start.as_path(); loop { let candidate = cur.join("runtime").join("rc.c"); if candidate.exists() { return Ok(candidate); } match cur.parent() { Some(p) => cur = p, None => break, } } } anyhow::bail!( "could not locate `runtime/rc.c` (required for --alloc=rc). \ Run `ail` from inside the AILang workspace." ) } /// Iter 9b: shared build helper for `Cmd::Build` and `Cmd::Run`. /// Loads the workspace, runs the typechecker, emits IR, and links via /// clang. On typecheck failure, prints diagnostics to stderr and exits /// the process with code 1. On clang failure, returns a Result error /// (the .ll path is preserved for post-mortem inspection). /// /// Iter 16b.3: between `check_workspace` and `lower_workspace` we run /// `ailang_check::lift_letrecs` per module. The lift eliminates any /// `Term::LetRec` that the desugar pass left in place (specifically: /// LetRecs that capture `Term::Let`-bound names, whose types are /// only known after typecheck). The lifted workspace then goes to /// codegen unchanged. /// /// Bench iter: `alloc` selects the heap allocator the emitted IR /// targets. Default `Gc` keeps the entire pipeline (IR text, link /// command) byte-identical to pre-bench. `Bump` declares /// `@bump_malloc` instead of `@GC_malloc` and links `runtime/bump.c` /// in lieu of `-lgc`. `Rc` (Iter 18b) declares `@ailang_rc_alloc` /// instead and links `runtime/rc.c` — RC runtime, alloc-only — Iter /// 18b plumbing; inc/dec instrumentation arrives in 18c. fn build_to( path: &Path, out: Option, opt: &str, alloc: ailang_codegen::AllocStrategy, ) -> Result { let ws = ailang_core::load_workspace(path)?; let diags = ailang_check::check_workspace(&ws); if !diags.is_empty() { for d in &diags { eprintln!( "{}: [{}] {}{}", match d.severity { ailang_check::Severity::Error => "error", ailang_check::Severity::Warning => "warning", }, d.code, d.def .as_ref() .map(|n| format!("{n}: ")) .unwrap_or_default(), d.message, ); } // Iter 19a: only Error-severity blocks the build. Warning- // severity diagnostics (e.g. `over-strict-mode`) print but // do not abort. if diags .iter() .any(|d| matches!(d.severity, ailang_check::Severity::Error)) { std::process::exit(1); } } // Iter 16b.3: run `lift_letrecs` per module on the post-desugar // form. Codegen's internal desugar pass is idempotent on a // module that contains no `Term::LetRec`, so the lifted output // can be handed directly to `lower_workspace`. let mut lifted_modules = std::collections::BTreeMap::new(); for (mname, m) in &ws.modules { let desugared = ailang_core::desugar::desugar_module(m); let lifted = ailang_check::lift_letrecs(&desugared) .map_err(|e| anyhow::anyhow!("lift_letrecs in module `{mname}`: {e}"))?; lifted_modules.insert(mname.clone(), lifted); } let ws = ailang_core::Workspace { entry: ws.entry.clone(), modules: lifted_modules, root_dir: ws.root_dir.clone(), // Iter 22b.1: pass the registry through the lift pass. Lift // only rewrites `Term::LetRec` into top-level fns; it does // not touch class/instance defs, so the registry built at // load-time remains valid. registry: ws.registry.clone(), }; let ir = ailang_codegen::lower_workspace_with_alloc(&ws, alloc)?; let tmpdir = std::env::temp_dir().join(format!("ailang-{}", std::process::id())); std::fs::create_dir_all(&tmpdir)?; let ll_path = tmpdir.join(format!("{}.ll", ws.entry)); std::fs::write(&ll_path, &ir)?; let out_bin = out.unwrap_or_else(|| Path::new(".").join(&ws.entry).with_extension("")); let mut clang = std::process::Command::new("clang"); clang.arg(opt).arg("-o").arg(&out_bin).arg(&ll_path); match alloc { ailang_codegen::AllocStrategy::Gc => { // Boehm conservative GC (Decision 9 / Iter 14f). The lowered // IR calls @GC_malloc; libgc supplies it. Pthread/dl are // pulled in transitively via libgc.so on Linux, so a single // -lgc suffices. clang.arg("-lgc"); } ailang_codegen::AllocStrategy::Bump => { // Bench iter: link the no-free arena stub from // `runtime/bump.c` instead of libgc. We compile the stub // inline at -O2 (its body is small, the .o is cached at // /bump.o per build invocation; no global cache // because the bench harness rebuilds binaries top-to-bottom // anyway). let bump_src = locate_bump_runtime()?; let bump_obj = tmpdir.join("bump.o"); let cstatus = std::process::Command::new("clang") .arg("-O2") .arg("-c") .arg(&bump_src) .arg("-o") .arg(&bump_obj) .status() .context("compiling runtime/bump.c")?; if !cstatus.success() { anyhow::bail!( "clang failed compiling bump.c (status {})", cstatus ); } clang.arg(&bump_obj); } ailang_codegen::AllocStrategy::Rc => { // Iter 18b: link the RC runtime stub from `runtime/rc.c`. // Provides `ailang_rc_alloc` / `inc` / `dec` against libc // malloc/free; no Boehm dependency, so we deliberately do // NOT pass `-lgc`. Compiled at -O2 to match the bump path // shape; cached at /rc.o per build invocation. // // 18b only routes allocation here — codegen does not yet // emit `inc`/`dec` calls, so programs leak under this // mode. The point is to validate compiled-program // correctness and the runtime ABI before 18c wires up // inc/dec emission. let rc_src = locate_rc_runtime()?; let rc_obj = tmpdir.join("rc.o"); let cstatus = std::process::Command::new("clang") .arg("-O2") .arg("-c") .arg(&rc_src) .arg("-o") .arg(&rc_obj) .status() .context("compiling runtime/rc.c")?; if !cstatus.success() { anyhow::bail!( "clang failed compiling rc.c (status {})", cstatus ); } clang.arg(&rc_obj); } } let status = clang.status().context("running clang")?; if !status.success() { anyhow::bail!( "clang failed (status {}); ll at {}", status, ll_path.display() ); } Ok(out_bin) } #[cfg(test)] mod tests { use super::compose_merge_prose_prompt; /// The composed prompt must contain all three payloads /// byte-for-byte — the LLM has to see exactly what the user wrote, /// the original module's Form-A bytes, and the embedded spec, with /// no re-encoding or stripping. #[test] fn merge_prose_prompt_contains_all_payloads_verbatim() { let orig = "(module foo\n (fn main\n (type (fn-type (params) (ret (con Unit))))\n (params)\n (body (lit-unit))))\n"; let prose = "// module foo\n\nfn main() -> Unit { () }\n"; let out = compose_merge_prose_prompt(orig, prose); assert!( out.contains(orig), "prompt missing original Form-A verbatim; got:\n{out}" ); assert!( out.contains(prose), "prompt missing edited prose verbatim; got:\n{out}" ); // The full FORM_A_SPEC must appear in the prompt. assert!( out.contains(ailang_core::FORM_A_SPEC), "prompt missing the FORM_A_SPEC body verbatim" ); } /// The framing sections must all be present so the LLM has the /// full task definition. Asserted by substring on a representative /// landmark from each section. #[test] fn merge_prose_prompt_has_all_framing_sections() { let out = compose_merge_prose_prompt("(module x)", ""); assert!( out.contains("ROLE") && out.contains("Form-A"), "prompt missing ROLE section" ); assert!( out.contains("CONTRACT") && out.contains("preserve those") && out.contains("(own T)") && out.contains("(effects IO)"), "prompt missing CONTRACT section" ); assert!( out.contains("OUTPUT") && out.contains("ONLY") && out.contains("no markdown"), "prompt missing OUTPUT section" ); // The embedded language spec, by section header. assert!( out.contains("FORM-A SPECIFICATION"), "prompt missing FORM-A SPECIFICATION header" ); // Heredoc-style markers around all three payloads. assert!( out.contains("<<