76b21c00eb
Deletes `ParamMode::Implicit`. `ParamMode` is now `{Own, Borrow}`:
every fn-type slot on every signature carries an explicit `own` or
`borrow`, no defaulted position survives anywhere (model 0008 §2,
spec 0062). The parser rejects a bare fn-type slot; `borrow-return`
and `borrow-over-value` reject at the signature; the corpus is
migrated to minimal-ownership modes (consumed ⇒ own, read-only-heap
⇒ borrow, value ⇒ trivial-own). The documented `Implicit`-ret-mode
leak is fixed: an owned heap return now drops exactly once (live=0,
acceptance criterion 5).
This was the easy half. Removing the default ACTIVATED a family of
drop paths that `Implicit` had silently skipped — the pre-cutover
language was leaking (and in places mis-dropping) here rather than
crashing, because an Implicit scrutinee turned the drop off. Making
the modes explicit (Own) turned those paths on and exposed two
latent-bug clusters, all fixed RED-first as part of this cutover:
Drop-soundness family (four legs):
A. lit-sub-pattern double-free — the desugar re-matched the same
owned scrutinee in the lit fall-through; fixed by grouping
consecutive same-ctor arms into one match (bind fields once),
in ailang-core desugar.
B. Cons-husk leak on non-tail arm bodies — the lit-sub-pattern
desugar rebound the owned scrutinee via `Let $mp = xs`, which
bumped consume_count and suppressed the existing fn-return
partial_drop. Fixed by not rebinding a bare-Var scrutinee
(one husk-freeing mechanism, not two).
C. polymorphic `drop_<T>` rc_dec'd monomorphised value fields —
the per-ADT drop fn was emitted once from the polymorphic
TypeDef, defaulting type-var fields to ptr and rc_dec'ing
inline Ints (segfault). Fixed with per-monomorph drop
functions (new ailang-codegen::dropmono): the drop set is
collected from the lowered MIR, value-type fields are skipped,
heap fields still freed once; monomorphic-concrete ADTs keep
their byte-identical un-suffixed drop symbol.
D. static Str literal passed to an `(own Str)` param — the
literal lowers to a header-less rodata constant; the callee's
now-active rc_dec read its length field as a refcount and
freed a static address (segfault). Fixed with the missing
fourth StrRep::Static→Heap promotion in lower_to_mir's App arm,
gated on Own mode (borrow args stay static, no regression).
over-strict-mode lint over-fired: it suggested `(borrow V)` for
value-typed params (which `borrow-over-value` rejects — own is the
only legal mode there) and fired on `(intrinsic)` bodies (whose
consumption the linearity walk cannot observe). Tightened to skip
both; contract 0008 updated to the narrowed firing scope.
Irreversible step — canonical-form hash reset (model 0008 §6,
acceptance criterion 6). Every signature now carries explicit modes,
so the hashable canonical JSON changed for every module. RATIFY:
the corpus-wide hash-pin reset (hash_pin, prelude_module_hash_pin,
mono_hash_stability, eq_ord_e2e, embed_export_hash_stable, the
ct4/iter*/loop_recur schema-extension pins) and the list ir_snapshot
golden were regenerated once, deliberately, as the intended one-time
consequence of removing the mode elision from the canonical form —
not a regression. Each regenerated hash verified deterministic across
two runs.
Also fixes a pre-existing latent failure surfaced by the verification
gate, unrelated to this cutover: the `every_contract_names_a_resolvable_
ratifying_test` resolver (design_index_pin) could not resolve the
" + " dual-link ratifying-test form (`uniqueness.rs + linearity.rs`)
that the #57 audit-close (dfdc65f) introduced — it shipped red on that
commit. Resolver taught the dual-link form, mirroring its sibling.
Verification: cargo test --workspace = 731 passed, 0 failed (twice,
stable); e2e 102 passed, no binary exits non-zero (corpus crash-free);
grep-clean for Implicit/fn_implicit/mode_eq across crates; every drop
fix confirmed via emitted IR + AILANG_RC_STATS balance on the head==K,
head!=K, and Nil paths. Three BLOCKEDs en route (the unsound first
husk-dec attempt, the over-strict derivation premise, the leg-B fix
direction) were each treated as a real design/spec gap and rediagnosed,
not patched over.
Supersedes #54 (return-position-only leak patch). Precondition #57
(linearity hardening) was already met. Spec docs/specs/0062, plan
docs/plans/0121.
closes #55
3061 lines
113 KiB
Rust
3061 lines
113 KiB
Rust
//! `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 (`<module>.<def>`);
|
|
/// 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 (`<module>.<def>`).
|
|
#[arg(long)]
|
|
of: Option<String>,
|
|
#[arg(long)]
|
|
json: bool,
|
|
/// Workspace mode: edges are cross-module aware
|
|
/// (`<from-mod>.<from-def> -> <to-mod>.<to-def>`).
|
|
#[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<PathBuf>,
|
|
/// 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 "<sym>")`
|
|
/// fn, no `@main` (Embedding ABI M1). Same `Target` the
|
|
/// `build --emit=staticlib` path uses; output is the IR text
|
|
/// (stdout, or `-o <file>`), 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<PathBuf>,
|
|
/// 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<entry>.a` (program objects only) plus a separate
|
|
/// `libailang_rt.a` (RC runtime), with one external C
|
|
/// entrypoint per `(export "<sym>")` 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<String>,
|
|
},
|
|
/// 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<PathBuf>,
|
|
},
|
|
/// 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 `<dir>` 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("<<<FORM_A_SPEC\n");
|
|
out.push_str(ailang_core::FORM_A_SPEC);
|
|
if !ailang_core::FORM_A_SPEC.ends_with('\n') {
|
|
out.push('\n');
|
|
}
|
|
out.push_str("FORM_A_SPEC\n\n");
|
|
out.push_str("ORIGINAL MODULE (Form-A)\n");
|
|
out.push_str("<<<ORIGINAL_FORM_A\n");
|
|
out.push_str(original_form_a);
|
|
if !original_form_a.ends_with('\n') {
|
|
out.push('\n');
|
|
}
|
|
out.push_str("ORIGINAL_FORM_A\n\n");
|
|
out.push_str("EDITED PROSE\n");
|
|
out.push_str("<<<EDITED_PROSE\n");
|
|
out.push_str(edited_prose);
|
|
if !edited_prose.ends_with('\n') {
|
|
out.push('\n');
|
|
}
|
|
out.push_str("EDITED_PROSE\n\n");
|
|
out.push_str("Emit the updated Form-A module now.\n");
|
|
out
|
|
}
|
|
|
|
fn main() -> 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 `<module>.<def> :: <type> ![effs] <hash>`.
|
|
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!(
|
|
"{:<width$} :: {}{} [{}]",
|
|
label,
|
|
ty,
|
|
eff,
|
|
ailang_core::def_hash(d),
|
|
width = label_width,
|
|
);
|
|
}
|
|
}
|
|
} else {
|
|
let m = ailang_surface::load_module(&path)?;
|
|
if json {
|
|
let entries: Vec<_> = 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<String, (PathBuf, Module)> = 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<String, BTreeSet<String>> =
|
|
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<String> =
|
|
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 \"<sym>\")` 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} {:<width$} {} {:>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
|
|
// (`<module>.<def>`) or a bare name (matches in all
|
|
// modules where the def exists).
|
|
let of_filter: Option<(Option<String>, 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:<eff>/<op>`
|
|
// (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<serde_json::Value> = 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<String> = 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<ailang_check::Diagnostic> {
|
|
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 <module>)` to bring it into scope (candidates: {candidates:?}), \
|
|
or rewrite the call to type-scoped form `<TypeName>.<member>`."
|
|
),
|
|
)
|
|
.with_ctx(serde_json::json!({
|
|
"module": module,
|
|
"name": name,
|
|
"candidates": candidates,
|
|
})),
|
|
),
|
|
// qualified `<owner>.<type>` where `<owner>` is not a
|
|
// known module or `<owner>` has no `TypeDef <type>`.
|
|
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 `<owner>.<class>` where `<owner>` is not a
|
|
// known module or `<owner>` has no class `<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(<path>)?` 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<ailang_core::Workspace> {
|
|
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 (`<prefix>.<def>`) 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<String> {
|
|
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<String> =
|
|
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<String>,
|
|
builtins: &std::collections::HashSet<&'static str>,
|
|
scope: &mut std::collections::HashSet<String>,
|
|
) {
|
|
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<String>,
|
|
) -> Vec<String> {
|
|
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<DiffEntry>,
|
|
removed: Vec<DiffEntry>,
|
|
changed: Vec<ChangedEntry>,
|
|
unchanged: Vec<DiffEntry>,
|
|
}
|
|
|
|
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<DiffEntry>, Vec<DiffEntry>, Vec<ChangedEntry>, Vec<DiffEntry>) {
|
|
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::<Vec<_>>(),
|
|
"removed": r.removed.iter().map(entry).collect::<Vec<_>>(),
|
|
"changed": r.changed.iter().map(changed).collect::<Vec<_>>(),
|
|
"unchanged": r.unchanged.iter().map(entry).collect::<Vec<_>>(),
|
|
})
|
|
}
|
|
|
|
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,
|
|
"+ {:<width$} ({}) {}",
|
|
e.name,
|
|
e.kind,
|
|
e.hash,
|
|
width = name_width
|
|
);
|
|
}
|
|
for e in &r.removed {
|
|
let _ = writeln!(
|
|
out,
|
|
"- {:<width$} ({}) {}",
|
|
e.name,
|
|
e.kind,
|
|
e.hash,
|
|
width = name_width
|
|
);
|
|
}
|
|
for c in &r.changed {
|
|
// If the kind changed (e.g. const → fn), show both.
|
|
let kind = if c.kind_a == c.kind_b {
|
|
c.kind_a.to_string()
|
|
} else {
|
|
format!("{} -> {}", c.kind_a, c.kind_b)
|
|
};
|
|
let _ = writeln!(
|
|
out,
|
|
"~ {:<width$} ({}) {} -> {}",
|
|
c.name,
|
|
kind,
|
|
c.hash_a,
|
|
c.hash_b,
|
|
width = name_width
|
|
);
|
|
}
|
|
for e in &r.unchanged {
|
|
let _ = writeln!(
|
|
out,
|
|
" {:<width$} ({}) {} (unchanged)",
|
|
e.name,
|
|
e.kind,
|
|
e.hash,
|
|
width = name_width
|
|
);
|
|
}
|
|
out
|
|
}
|
|
|
|
// --- Workspace-aware helpers (Iter 5d) ------------------------------------
|
|
|
|
/// Compact summary of a def for manifest output (single and workspace
|
|
/// mode share this helper). Returns (kind, type-string, effects).
|
|
fn def_summary(d: &ailang_core::Def) -> (&'static str, String, Vec<String>) {
|
|
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::<Vec<_>>()
|
|
.join(", ")
|
|
)
|
|
}
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.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 <name>`.
|
|
///
|
|
/// 1. `name` contains exactly one dot → resolve `<module>.<def>` 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<String> =
|
|
hits.iter().map(|(m, _)| m.clone()).collect();
|
|
Err(anyhow::anyhow!(
|
|
"[ambiguous-name] def `{name}` exists in multiple modules: {}",
|
|
modules.join(", ")
|
|
))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Map `<prefix> -> <module-name>` for a single module. `<prefix>` 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<String, String> {
|
|
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<ModuleDiffEntry>,
|
|
removed_modules: Vec<ModuleDiffEntry>,
|
|
unchanged_modules: Vec<ModuleDiffEntry>,
|
|
changed_modules: Vec<ChangedModuleEntry>,
|
|
}
|
|
|
|
struct ModuleDiffEntry {
|
|
name: String,
|
|
hash: String,
|
|
}
|
|
|
|
struct ChangedModuleEntry {
|
|
name: String,
|
|
hash_a: String,
|
|
hash_b: String,
|
|
added: Vec<DiffEntry>,
|
|
removed: Vec<DiffEntry>,
|
|
changed: Vec<ChangedEntry>,
|
|
unchanged: Vec<DiffEntry>,
|
|
}
|
|
|
|
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::<Vec<_>>(),
|
|
"removed": c.removed.iter().map(entry).collect::<Vec<_>>(),
|
|
"changed": c.changed.iter().map(changed_entry).collect::<Vec<_>>(),
|
|
"unchanged": c.unchanged.iter().map(entry).collect::<Vec<_>>(),
|
|
})
|
|
};
|
|
serde_json::json!({
|
|
"workspace_a": r.workspace_a,
|
|
"workspace_b": r.workspace_b,
|
|
"added_modules": r.added_modules.iter().map(mod_entry).collect::<Vec<_>>(),
|
|
"removed_modules": r.removed_modules.iter().map(mod_entry).collect::<Vec<_>>(),
|
|
"unchanged_modules": r.unchanged_modules.iter().map(mod_entry).collect::<Vec<_>>(),
|
|
"changed_modules": r.changed_modules.iter().map(changed_mod).collect::<Vec<_>>(),
|
|
})
|
|
}
|
|
|
|
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<ailang_codegen::AllocStrategy> {
|
|
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<PathBuf> {
|
|
// 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<PathBuf> {
|
|
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<PathBuf> {
|
|
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<PathBuf>,
|
|
opt: &str,
|
|
alloc: ailang_codegen::AllocStrategy,
|
|
) -> Result<PathBuf> {
|
|
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 <tmpdir>/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
|
|
// <tmpdir>/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
|
|
/// `<outdir>/lib<entry>.a` (program objects only) and a separate
|
|
/// `<outdir>/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<PathBuf>,
|
|
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 \"<sym>\")` 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<entry>.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<entry>.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<String, std::collections::BTreeSet<String>>,
|
|
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<String, std::collections::BTreeSet<String>>,
|
|
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<String, std::collections::BTreeSet<String>>,
|
|
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<String, std::collections::BTreeSet<String>>,
|
|
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("<<<FORM_A_SPEC")
|
|
&& out.contains("FORM_A_SPEC\n")
|
|
&& out.contains("<<<ORIGINAL_FORM_A")
|
|
&& out.contains("ORIGINAL_FORM_A\n")
|
|
&& out.contains("<<<EDITED_PROSE")
|
|
&& out.contains("EDITED_PROSE\n"),
|
|
"prompt missing payload markers"
|
|
);
|
|
}
|
|
|
|
/// Pure / deterministic: same inputs must yield byte-identical
|
|
/// output.
|
|
#[test]
|
|
fn merge_prose_prompt_is_deterministic() {
|
|
let orig = "(module y)";
|
|
let prose = "fn f() {}";
|
|
let a = compose_merge_prose_prompt(orig, prose);
|
|
let b = compose_merge_prose_prompt(orig, prose);
|
|
assert_eq!(a, b);
|
|
}
|
|
}
|