//! `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_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, /// IR shape. `exe` (default): whole-program IR (requires an /// entry `main`). `staticlib`: kernel IR for a `main`-free /// module — one external C forwarder per `(export "")` /// fn, no `@main` (Embedding ABI M1). Same `Target` the /// `build --emit=staticlib` path uses; output is the IR text /// (stdout, or `-o `), not archives. #[arg(long, default_value = "exe", value_parser = ["exe", "staticlib"])] emit: String, }, /// 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, /// Allocator backend. `rc` is the canonical production /// allocator (reference counting + uniqueness inference); /// codegen routes allocation through `runtime/rc.c`'s /// `@ailang_rc_alloc` with `ailang_rc_inc`/`_dec` instrumentation. /// `bump` is a raw-alloc bench-floor (no free; leak-tolerant; /// `runtime/bump.c`'s 256MB arena); bench-only, not a /// production target. #[arg(long, default_value = "rc")] alloc: String, /// Output shape. `exe` (default): whole-program executable /// (requires an entry `main`). `staticlib`: a relocatable /// `lib.a` (program objects only) plus a separate /// `libailang_rt.a` (RC runtime), with one external C /// entrypoint per `(export "")` fn and no `@main` /// (Embedding ABI M1). With `staticlib`, `-o` is a directory. #[arg(long, default_value = "exe", value_parser = ["exe", "staticlib"])] emit: 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")] 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. /// /// 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 `.ail` 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, }, /// 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 }, /// 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, }, /// Dev-only: rewrite every `*.ail.json` under `` so /// that bare cross-module `Type::Con` and `Term::Ctor.type_name` /// references gain their owning module's qualifier. Idempotent. /// /// Disambiguation: the first import (in declaration order) that /// owns a matching type wins. Ties are NOT diagnosed — the /// migration silently picks the first match. (The prelude /// last-resort fallback was retired in pd.3 alongside the /// `prelude.ail.json` embed; bare cross-module refs that resolve /// only via the prelude are now left bare and surfaced by the /// validator instead.) MigrateCanonicalTypes { /// Directory containing `*.ail.json` to migrate (typically /// `examples/`). dir: 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. /// /// 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) 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 .ail 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 (e.g. `(effects IO)`). 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 = load_workspace_human(&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_surface::load_module(&path)?; print!("{}", ailang_surface::print(&m)); } Cmd::Prose { path } => { // load via `load_module` (single-module mode, // matching how `render` / `parse` work). Workspace-wide // prose is not in scope for 20a. let m = ailang_surface::load_module(&path)?; print!("{}", ailang_prose::module_to_prose(&m)); } Cmd::MergeProse { original, edited } => { // load the original .ail.json, render it as // Form-A (the canonical authoring surface), // 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_surface::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::MigrateCanonicalTypes { dir } => { use ailang_core::ast::{Def, Module}; use std::collections::{BTreeMap, BTreeSet}; use std::fs; // Pass 1: load every *.ail.json in `dir` into a map by // module name (NOT via load_workspace — these fixtures // may currently fail validation). let mut modules: BTreeMap = BTreeMap::new(); for entry in fs::read_dir(&dir)? { let entry = entry?; let path = entry.path(); if path.extension().and_then(|s| s.to_str()) != Some("json") { continue; } let bytes = fs::read(&path)?; let m: Module = match serde_json::from_slice(&bytes) { Ok(m) => m, Err(_) => continue, // not a Module (e.g. snapshot files) }; modules.insert(m.name.clone(), (path, m)); } // Pre-pass: per-module local-types index. let mut local_types: BTreeMap> = BTreeMap::new(); for (name, (_, m)) in &modules { let mut s = BTreeSet::new(); for d in &m.defs { if let Def::Type(t) = d { s.insert(t.name.clone()); } } local_types.insert(name.clone(), s); } // Pass 2: rewrite each module's bare cross-module refs. let mut rewritten = 0usize; for (mod_name, (path, m)) in modules.iter_mut() { let import_names: Vec = m.imports.iter().map(|i| i.module.clone()).collect(); let owning = mod_name.clone(); let mut changed = false; for def in &mut m.defs { rewrite_def( def, &owning, &local_types, &import_names, &mut changed, ); } if changed { let bytes = serde_json::to_vec_pretty(m)?; fs::write(&*path, bytes)?; println!("migrated: {}", path.display()); rewritten += 1; } } println!("done. {} file(s) rewritten.", rewritten); } Cmd::Describe { path, name, json, workspace } => { if workspace { let ws = load_workspace_human(&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(), kernel: false, 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_surface::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(), kernel: false, 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 } => { // `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_surface::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 = load_workspace_human(&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, ); } // 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, emit } => { // workspace lowering. For single-module programs the // workspace is effectively a trivial workspace with one module. let ws = load_workspace_human(&path)?; // staticlib needs ≥1 export — checked on the source AST // (structural; survives elaborate). Done before elaborate so // the message is clear rather than a missing-main lowering. if emit == "staticlib" { let has_export = ws.modules.values().any(|m| m.defs.iter().any(|d| matches!(d, ailang_core::Def::Fn(f) if f.export.is_some()))); if !has_export { anyhow::bail!( "staticlib target needs at least one `(export \"\")` fn" ); } } // mir.1b OQ4: emit-ir runs the same single front-end as // `build` via `elaborate_workspace` (check → desugar+lift → // mono → lower_to_mir), driving error diagnostics from its // `Err` rather than a separate `check_workspace` call. let mir = match ailang_check::elaborate_workspace(&ws) { Ok(mir) => mir, Err(diags) => { print_build_diagnostics(&diags); std::process::exit(1); } }; let ir = if emit == "staticlib" { ailang_codegen::lower_workspace_staticlib(&mir)? } else { ailang_codegen::lower_workspace(&mir)? }; match out { Some(p) => { std::fs::write(&p, ir)?; eprintln!("wrote {}", p.display()); } None => print!("{ir}"), } } Cmd::Build { path, out, opt, alloc, emit } => { let strategy = parse_alloc_strategy(&alloc)?; if emit == "staticlib" { let (lib, rt) = build_staticlib(&path, out, &opt, strategy)?; eprintln!("built {} + {}", lib.display(), rt.display()); } else { let bin = build_to(&path, out, &opt, strategy)?; eprintln!("built {}", bin.display()); } } Cmd::Run { path, opt, alloc, args } => { // 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 = load_workspace_human(&a)?; let ws_b = load_workspace_human(&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_surface::load_module(&a)?; let mb = ailang_surface::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 = load_workspace_human(&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 .ail, 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 = load_workspace_human(&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_surface::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, })), ), // typeclass-coherence diagnostics emitted from // `workspace::build_registry`. The codes follow the // diagnostic-categories section of // `design/contracts/0013-typeclasses.md`. 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::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::MissingSuperclassInstance { class, superclass, type_repr, } => Some( ailang_check::Diagnostic::error( "missing-superclass-instance", format!( "instance `{class} {type_repr}` requires superclass instance `{superclass} {type_repr}`, but none was found" ), ) .with_ctx(serde_json::json!({ "class": class, "superclass": superclass, "type": type_repr, })), ), // the user named a module whose name collides with a // built-in kernel-tier module that the loader auto-injects // (e.g. `prelude`). Since prep.3 of the kernel-extension- // mechanics milestone, the reserved set is derived from the // kernel-tier filter; the diagnostic message generalises // accordingly. W::ReservedModuleName { name } => Some( ailang_check::Diagnostic::error( "reserved-module-name", format!( "module `{name}` collides with a built-in kernel-tier module name (reserved)" ), ) .with_ctx(serde_json::json!({ "module": name, })), ), // the canonical-form rule for type references: bare `Type::Con` that does not // resolve to a primitive or a local TypeDef of the owning // module. `candidates` lists qualified suggestions scanned // from imports. W::BareCrossModuleTypeRef { module, name, candidates } => Some( ailang_check::Diagnostic::error( "bare-cross-module-type-ref", format!( "module `{module}` references bare type `{name}`, which is not in scope. \ Add `(import )` to bring it into scope (candidates: {candidates:?}), \ or rewrite the call to type-scoped form `.`." ), ) .with_ctx(serde_json::json!({ "module": module, "name": name, "candidates": candidates, })), ), // qualified `.` where `` is not a // known module or `` has no `TypeDef `. W::BadCrossModuleTypeRef { module, name } => Some( ailang_check::Diagnostic::error( "bad-cross-module-type-ref", format!( "module `{module}` references qualified type `{name}` but the owner module \ is not known in the workspace, or it declares no type by that name. \ Use the bare type-name from an imported module instead." ), ) .with_ctx(serde_json::json!({ "module": module, "name": name, })), ), // the canonical-form rule for class references: bare class-ref that does not // resolve to a local class of the owning module. Sibling of // `BareCrossModuleTypeRef` for class-reference fields. W::BareCrossModuleClassRef { module, name, candidates } => Some( ailang_check::Diagnostic::error( "bare-cross-module-class-ref", format!( "module `{module}` contains bare class name `{name}` that does not resolve to a local class; \ candidates from imports: {candidates:?}" ), ) .with_ctx(serde_json::json!({ "module": module, "name": name, "candidates": candidates, })), ), // qualified `.` where `` is not a // known module or `` has no class ``. W::BadCrossModuleClassRef { module, name } => Some( ailang_check::Diagnostic::error( "bad-cross-module-class-ref", format!( "module `{module}` references qualified class `{name}` but the owner module is not known \ or does not declare a class by that name" ), ) .with_ctx(serde_json::json!({ "module": module, "name": name, })), ), // a class-reference field contains a `.` — class names // are not module-qualified in this milestone. W::QualifiedClassName { module, name, field } => Some( ailang_check::Diagnostic::error( "qualified-class-name", format!( "module `{module}` contains qualified class name `{name}` in field `{field}`; \ class names are not module-qualified in this milestone" ), ) .with_ctx(serde_json::json!({ "module": module, "name": name, "field": field, })), ), // ext-cli.1: surface-side parse failure on a `.ail` (Form A) // source file. Routed through the same `Diagnostic` channel as // schema/coherence errors so `ail check --json foo.ail` on a // broken `.ail` returns a parseable JSON array instead of the // misleading JSON-parse fall-through. W::SurfaceParse { path, message } => Some( ailang_check::Diagnostic::error( "surface-parse-error", message.clone(), ) .with_ctx(serde_json::json!({ "path": path.display().to_string(), })), ), } } /// loads a workspace and, on /// `WorkspaceLoadError`, routes through `workspace_error_to_diagnostic` /// to print a stderr line matching the JSON path's `[code]`-bracketed /// format before exiting non-zero. Pure I/O errors have no diagnostic /// equivalent (see `workspace_error_to_diagnostic` returning `None` /// for `W::Io`) and still propagate as anyhow errors so the caller's /// `?`-flow stays well-behaved. /// /// Replaces the bare `ailang_surface::load_workspace()?` shape /// in every non-JSON CLI subcommand. The JSON path of `ail check` /// (which builds a structured diagnostics array on stdout) is the /// one site that keeps the explicit `match` against the loader /// result — it does not call this helper. fn load_workspace_human( path: &Path, ) -> Result { match ailang_surface::load_workspace(path) { Ok(ws) => Ok(ws), Err(e) => match workspace_error_to_diagnostic(&e) { Some(d) => { eprintln!( "error: [{}] {}{}", d.code, d.def .as_ref() .map(|n| format!("{n}: ")) .unwrap_or_default(), d.message, ); std::process::exit(1); } None => Err(anyhow::anyhow!(e)), }, } } /// 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; qualified cross-module references are code-SoT — see /// the `qualified-xref` row in design/INDEX.md). 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}")); } } } } // `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_str` 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, .. } => { // `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 } => { // clone is identity for dependency-walk // purposes. The wrapper introduces no new symbols. walk_term(value, out, builtins, scope); } Term::ReuseAs { source, body } => { // walk both children; the wrapper introduces no // new bindings of its own. walk_term(source, out, builtins, scope); walk_term(body, out, builtins, scope); } Term::Loop { binders, body } => { let mut newly = Vec::new(); for b in binders { walk_term(&b.init, out, builtins, scope); if scope.insert(b.name.clone()) { newly.push(b.name.clone()); } } walk_term(body, out, builtins, scope); for n in newly { scope.remove(&n); } } Term::Recur { args } => { for a in args { walk_term(a, out, builtins, scope); } } // prep.2 (kernel-extension-mechanics): record the type-name // dependency on the home module's TypeDef (mirrors the // `Term::Ctor` arm above's `ctor:` insertion shape) and // recurse into NewArg::Value subterms. NewArg::Type values // are not walked for cross-module type dependencies here — // `ail deps` works on the surface AST before workspace // qualification, so embedded type names ride the existing // `walk_type`/`walk_def_types` channels. Term::New { type_name, args } => { out.insert(format!("new:{type_name}")); for arg in args { if let ailang_core::ast::NewArg::Value(v) = arg { walk_term(v, out, builtins, scope); } } } Term::Intrinsic => {} } } /// 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/contracts/0002-data-model.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![]) } // 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 { "bump" => Ok(ailang_codegen::AllocStrategy::Bump), "rc" => Ok(ailang_codegen::AllocStrategy::Rc), other => anyhow::bail!( "unknown --alloc value `{other}` (expected `rc` or `bump`)" ), } } /// 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 compiles this stub for the canonical RC build; 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." ) } /// Locate the workspace-root `runtime/str.c` file relative to the /// `ail` binary, mirroring [`locate_rc_runtime`]. Iter 23.2: /// `runtime/str.c` houses Str-runtime primitives (`ail_str_eq`, /// later `ail_str_compare`) and is linked unconditionally for every /// alloc strategy — `@ail_str_eq` is declared in the emitted IR /// header (codegen) without an alloc-strategy guard, so the symbol /// must always be resolvable at link time. fn locate_str_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("str.c"); if candidate.exists() { return Ok(candidate); } match cur.parent() { Some(p) => cur = p, None => break, } } } anyhow::bail!( "could not locate `runtime/str.c` (linked unconditionally). \ Run `ail` from inside the AILang workspace." ) } /// print build-time diagnostics to stderr in the `ail check` human /// format (severity / code / optional def name / message). Used by the /// build paths to surface the `Err(diags)` from `elaborate_workspace` /// before exiting with code 1. fn print_build_diagnostics(diags: &[ailang_check::Diagnostic]) { 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, ); } } /// shared build helper for `Cmd::Build` and `Cmd::Run`. /// Loads the workspace, elaborates it to MIR (check → desugar+lift → /// monomorphise → lower_to_mir), 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). /// /// `alloc` selects the heap allocator the emitted IR targets. /// Default `Rc` is the canonical production path: declares /// `@ailang_rc_alloc` (with `ailang_rc_inc` / `ailang_rc_dec` /// instrumentation) and links `runtime/rc.c`. `Bump` declares /// `@bump_malloc` for the bump bench-floor; links /// `runtime/bump.c` statically. fn build_to( path: &Path, out: Option, opt: &str, alloc: ailang_codegen::AllocStrategy, ) -> Result { let ws = load_workspace_human(path)?; // mir.1b OQ4: one check, not two. `elaborate_workspace` runs the // full front-end (check → desugar+lift → monomorphise → // lower_to_mir) and returns `Err(diags)` on a type error; the // build path no longer runs its own `check_workspace`. The error // diagnostics drive the same human-format print + exit(1) the // separate check used to. let mir = match ailang_check::elaborate_workspace(&ws) { Ok(mir) => mir, Err(diags) => { print_build_diagnostics(&diags); std::process::exit(1); } }; let ir = ailang_codegen::lower_workspace_with_alloc(&mir, 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); // compile and link `runtime/str.c` unconditionally — // `@ail_str_eq` is emitted in the IR header without an alloc-strategy // guard (it backs the prelude `eq__Str` mono symbol). The .o is // cached at /str.o per build invocation; if a program never // monomorphises `eq__Str`, clang -O2 dead-strips the symbol so the // linker overhead is fixed and negligible. let str_src = locate_str_runtime()?; let str_obj = tmpdir.join("str.o"); let cstatus = std::process::Command::new("clang") .arg("-O2") .arg("-c") .arg(&str_src) .arg("-o") .arg(&str_obj) .status() .context("compiling runtime/str.c")?; if !cstatus.success() { anyhow::bail!( "clang failed compiling str.c (status {})", cstatus ); } clang.arg(&str_obj); // compile and link `runtime/rc.c` unconditionally, // hoisted out of the AllocStrategy::Rc arm. `runtime/str.c`'s // `ailang_int_to_str` / `ailang_float_to_str` call // `ailang_rc_alloc` defined in rc.c; the weak extern declaration // in str.c (iter hs.3) resolves to the strong definition here. // Under --alloc=bump the alloc strategy still governs ADT // allocation (bump_malloc); rc.c provides the heap-Str primitives // in parallel. clang -O2 dead-strips the rc.c symbols when no // caller exists in the final binary, so the link-time overhead // under bump is negligible. 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); match alloc { ailang_codegen::AllocStrategy::Bump => { // Link the no-free arena stub from `runtime/bump.c` for // the bump bench-floor build. 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 => { // rc.c compile-and-link hoisted to the // unconditional path above. `--alloc=rc` is still a // valid CLI flag — it drives codegen's // `@ailang_rc_alloc`-vs-`@bump_malloc` selection in the IR // header — so the match arm is retained, but its body // is empty (no rc-specific linking work remains). } } let status = clang.status().context("running clang")?; if !status.success() { anyhow::bail!( "clang failed (status {}); ll at {}", status, ll_path.display() ); } Ok(out_bin) } /// Embedding-ABI M1: `ail build --emit=staticlib`. Produces /// `/lib.a` (program objects only) and a separate /// `/libailang_rt.a` (RC runtime: rc.c + str.c). No /// executable, no `@main`. `out` is the output directory. fn build_staticlib( path: &Path, out: Option, opt: &str, alloc: ailang_codegen::AllocStrategy, ) -> Result<(PathBuf, PathBuf)> { // ---- shared front matter: elaborate to MIR (one check) ---- let ws = load_workspace_human(path)?; // require ≥1 export on the source AST (structural; export survives // desugar/lift/mono unchanged). Checked before elaborate so a // no-export staticlib fails with the clear message rather than on a // missing-`@main`-free lowering. let has_export = ws.modules.values().any(|m| m.defs.iter().any(|d| matches!(d, ailang_core::Def::Fn(f) if f.export.is_some()))); if !has_export { anyhow::bail!( "staticlib target needs at least one `(export \"\")` fn" ); } if !matches!(alloc, ailang_codegen::AllocStrategy::Rc) { anyhow::bail!( "staticlib (swarm) artefact is RC-only — `--alloc=bump` links a \ leak-only bench instrument, not swarm-safe; use `--alloc=rc`" ); } let mir = match ailang_check::elaborate_workspace(&ws) { Ok(mir) => mir, Err(diags) => { print_build_diagnostics(&diags); std::process::exit(1); } }; let ir = ailang_codegen::lower_workspace_staticlib_with_alloc(&mir, 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 outdir = out.unwrap_or_else(|| PathBuf::from(".")); std::fs::create_dir_all(&outdir)?; // program object → lib.a (program objects ONLY — no rt) let prog_obj = tmpdir.join(format!("{}.o", ws.entry)); run_cmd("clang", &[opt, "-c", "-o", prog_obj.to_str().unwrap(), ll_path.to_str().unwrap()], "compiling program .ll")?; let prog_archive = outdir.join(format!("lib{}.a", ws.entry)); let _ = std::fs::remove_file(&prog_archive); run_cmd("ar", &["rcs", prog_archive.to_str().unwrap(), prog_obj.to_str().unwrap()], "archiving lib.a")?; // RC runtime → libailang_rt.a (rc.c + str.c), separate archive let rc_src = locate_rc_runtime()?; let str_src = locate_str_runtime()?; let rc_obj = tmpdir.join("rc.o"); let str_obj = tmpdir.join("str.o"); run_cmd("clang", &["-O2", "-c", "-o", rc_obj.to_str().unwrap(), rc_src.to_str().unwrap()], "compiling runtime/rc.c")?; run_cmd("clang", &["-O2", "-c", "-o", str_obj.to_str().unwrap(), str_src.to_str().unwrap()], "compiling runtime/str.c")?; let rt_archive = outdir.join("libailang_rt.a"); let _ = std::fs::remove_file(&rt_archive); run_cmd("ar", &["rcs", rt_archive.to_str().unwrap(), rc_obj.to_str().unwrap(), str_obj.to_str().unwrap()], "archiving libailang_rt.a")?; Ok((prog_archive, rt_archive)) } /// Run an external command, bailing with stderr on non-zero exit. fn run_cmd(bin: &str, args: &[&str], ctx: &str) -> Result<()> { let status = std::process::Command::new(bin) .args(args).status().context(ctx.to_string())?; if !status.success() { anyhow::bail!("{ctx}: `{bin}` exited {status}"); } Ok(()) } /// Canonical-form migration helper: rewrite bare cross-module Type::Con names /// and Term::Ctor.type_name to their qualified form. Sets /// `*changed` to `true` if any rewrite happened. Uses the /// first-match-in-imports-order disambiguation rule with prelude as /// implicit last-resort fallback, matching iter 23.1.3's typecheck /// imports-fallback so the rewrite never silently re-aims a fixture. fn rewrite_def( def: &mut ailang_core::ast::Def, owning_module: &str, local_types: &std::collections::BTreeMap>, import_names: &[String], changed: &mut bool, ) { use ailang_core::ast::{Def, Term, Type}; fn rewrite_name( name: &mut String, owning_module: &str, local_types: &std::collections::BTreeMap>, import_names: &[String], changed: &mut bool, ) { if name.contains('.') { return; } if matches!( name.as_str(), "Int" | "Bool" | "Str" | "Unit" | "Float" ) { return; } if local_types .get(owning_module) .map(|s| s.contains(name)) .unwrap_or(false) { return; } // Bare cross-module — find owner via imports-in-order. The // implicit-prelude fallback that this helper used to carry // (an `or_else` arm scanning `local_types["prelude"]`) was // dead-by-construction after the prelude-decouple milestone // (2026-05-14): the synthetic prelude entry that the migrate- // canonical-types subcommand used to insert into `modules` // was removed in iter pd.3, so `local_types["prelude"]` is // never populated. Removed in audit-pd-tidy. If a future // migrate variant needs prelude awareness, repopulate // `local_types["prelude"]` explicitly via `parse_prelude`. let owner: Option<&str> = import_names .iter() .map(|s| s.as_str()) .find(|imp| { local_types .get(*imp) .map(|s| s.contains(name)) .unwrap_or(false) }); if let Some(owner) = owner { *name = format!("{owner}.{name}"); *changed = true; } // If no owner found, leave bare — validator will reject later. } fn rewrite_type( t: &mut Type, owning_module: &str, local_types: &std::collections::BTreeMap>, import_names: &[String], changed: &mut bool, ) { match t { Type::Con { name, args } => { rewrite_name( name, owning_module, local_types, import_names, changed, ); for a in args { rewrite_type( a, owning_module, local_types, import_names, changed, ); } } Type::Fn { params, ret, .. } => { for p in params { rewrite_type( p, owning_module, local_types, import_names, changed, ); } rewrite_type( ret, owning_module, local_types, import_names, changed, ); } Type::Forall { body, constraints, .. } => { for c in constraints { rewrite_type( &mut c.type_, owning_module, local_types, import_names, changed, ); } rewrite_type( body, owning_module, local_types, import_names, changed, ); } Type::Var { .. } => {} } } fn rewrite_term( t: &mut Term, owning_module: &str, local_types: &std::collections::BTreeMap>, import_names: &[String], changed: &mut bool, ) { match t { Term::Lit { .. } | Term::Var { .. } => {} Term::App { callee, args, .. } => { rewrite_term( callee, owning_module, local_types, import_names, changed, ); for a in args { rewrite_term( a, owning_module, local_types, import_names, changed, ); } } Term::Let { value, body, .. } => { rewrite_term( value, owning_module, local_types, import_names, changed, ); rewrite_term( body, owning_module, local_types, import_names, changed, ); } Term::LetRec { ty, body, in_term, .. } => { rewrite_type( ty, owning_module, local_types, import_names, changed, ); rewrite_term( body, owning_module, local_types, import_names, changed, ); rewrite_term( in_term, owning_module, local_types, import_names, changed, ); } Term::If { cond, then, else_ } => { rewrite_term( cond, owning_module, local_types, import_names, changed, ); rewrite_term( then, owning_module, local_types, import_names, changed, ); rewrite_term( else_, owning_module, local_types, import_names, changed, ); } Term::Do { args, .. } => { for a in args { rewrite_term( a, owning_module, local_types, import_names, changed, ); } } Term::Ctor { type_name, args, .. } => { rewrite_name( type_name, owning_module, local_types, import_names, changed, ); for a in args { rewrite_term( a, owning_module, local_types, import_names, changed, ); } } Term::Match { scrutinee, arms } => { rewrite_term( scrutinee, owning_module, local_types, import_names, changed, ); for arm in arms { rewrite_term( &mut arm.body, owning_module, local_types, import_names, changed, ); } } Term::Lam { param_tys, ret_ty, body, .. } => { for pt in param_tys { rewrite_type( pt, owning_module, local_types, import_names, changed, ); } rewrite_type( ret_ty, owning_module, local_types, import_names, changed, ); rewrite_term( body, owning_module, local_types, import_names, changed, ); } Term::Seq { lhs, rhs } => { rewrite_term( lhs, owning_module, local_types, import_names, changed, ); rewrite_term( rhs, owning_module, local_types, import_names, changed, ); } Term::Clone { value } => rewrite_term( value, owning_module, local_types, import_names, changed, ), Term::ReuseAs { source, body } => { rewrite_term( source, owning_module, local_types, import_names, changed, ); rewrite_term( body, owning_module, local_types, import_names, changed, ); } Term::Loop { binders, body } => { for b in binders { rewrite_type( &mut b.ty, owning_module, local_types, import_names, changed, ); rewrite_term( &mut b.init, owning_module, local_types, import_names, changed, ); } rewrite_term( body, owning_module, local_types, import_names, changed, ); } Term::Recur { args } => { for a in args { rewrite_term( a, owning_module, local_types, import_names, changed, ); } } // prep.2 (kernel-extension-mechanics): `(new T args)` — // rewrite the bare type-name through the same // `rewrite_name` channel that `Term::Ctor` uses, then // recurse into each NewArg::Value subterm; NewArg::Type // values rewrite through `rewrite_type`. Term::New { type_name, args } => { rewrite_name( type_name, owning_module, local_types, import_names, changed, ); for arg in args { match arg { ailang_core::ast::NewArg::Value(v) => rewrite_term( v, owning_module, local_types, import_names, changed, ), ailang_core::ast::NewArg::Type(t) => rewrite_type( t, owning_module, local_types, import_names, changed, ), } } } Term::Intrinsic => {} } } match def { Def::Fn(fd) => { rewrite_type( &mut fd.ty, owning_module, local_types, import_names, changed, ); rewrite_term( &mut fd.body, owning_module, local_types, import_names, changed, ); } Def::Const(cd) => { rewrite_type( &mut cd.ty, owning_module, local_types, import_names, changed, ); rewrite_term( &mut cd.value, owning_module, local_types, import_names, changed, ); } Def::Type(td) => { for c in &mut td.ctors { for fty in &mut c.fields { rewrite_type( fty, owning_module, local_types, import_names, changed, ); } } } Def::Class(cd) => { for cm in &mut cd.methods { rewrite_type( &mut cm.ty, owning_module, local_types, import_names, changed, ); if let Some(body) = &mut cm.default { rewrite_term( body, owning_module, local_types, import_names, changed, ); } } } Def::Instance(id) => { rewrite_type( &mut id.type_, owning_module, local_types, import_names, changed, ); for im in &mut id.methods { rewrite_term( &mut im.body, owning_module, local_types, import_names, changed, ); } } } } #[cfg(test)] mod tests { use super::compose_merge_prose_prompt; #[test] fn locate_str_runtime_finds_workspace_str_c() { let p = super::locate_str_runtime().expect("locate runtime/str.c"); assert!( p.ends_with("runtime/str.c"), "expected path ending in runtime/str.c, got {}", p.display() ); assert!(p.exists(), "located path must exist on disk: {}", p.display()); } /// 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("<<