diff --git a/crates/ail/src/main.rs b/crates/ail/src/main.rs index 4c8a9eb..d96e874 100644 --- a/crates/ail/src/main.rs +++ b/crates/ail/src/main.rs @@ -1,8 +1,51 @@ //! `ail` — CLI for AILang. //! -//! Subcommands are sliced so that each individual tool gives the LLM a -//! small, focused context (manifest = overview; describe = -//! detail; emit-ir = exact machine view; build = pipeline validation). +//! 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` — pretty-print a `.ail.json` module as the textual +//! `.ail` projection. +//! - `describe` — full detail of a single definition, JSON or text. +//! - `deps` — static call edges per def (or for one named def); +//! workspace mode emits cross-module edges. +//! - `check` — load + schema-validate + typecheck. Emits structured +//! diagnostics with `--json`; exit code 1 on any error. +//! - `emit-ir` — write LLVM IR (`.ll`) for the module. Useful for +//! inspecting the generated code without invoking `clang`. +//! - `build` — full pipeline (`check` → `emit-ir` → `clang`) producing +//! a native binary at `--out`. +//! - `run` — `build` into a tempdir and execute the binary; passes +//! the binary's exit code through. +//! - `builtins` — list the built-in effect ops (`io/print_int`, +//! `io/print_bool`, `io/print_str`, ...) with their signatures. +//! - `diff` — semantic, hash-based module/workspace diff. Works even +//! when a module doesn't currently typecheck. +//! - `workspace` — load an entry module's transitive imports and list +//! the reachable modules with hash and def count. +//! +//! # External tooling +//! +//! `build` and `run` shell out to `clang` to link the emitted IR into +//! a native binary. `clang` must therefore be on `PATH` for those +//! subcommands; the other subcommands have no external dependencies. +//! +//! # rustdoc surface +//! +//! This is a binary crate with no `pub` items. The user-facing CLI +//! help is generated by `clap` from the `#[command(...)]` doc +//! comments on `Cmd` variants and surfaces as `ail --help`, not as +//! rustdoc. Private helpers are intentionally undocumented at the +//! rustdoc level — read the source. use anyhow::{Context, Result}; use clap::{Parser, Subcommand}; diff --git a/crates/ailang-codegen/src/lib.rs b/crates/ailang-codegen/src/lib.rs index b71e699..4a196ff 100644 --- a/crates/ailang-codegen/src/lib.rs +++ b/crates/ailang-codegen/src/lib.rs @@ -1,5 +1,13 @@ //! LLVM IR text emitter for AILang (MVP). //! +//! Third stage of the compiler pipeline (`core` → `check` → `codegen` +//! → `ail` CLI). Consumes a fully type-checked [`Module`] (single-file +//! mode) or [`Workspace`] (multi-module mode) and produces LLVM IR as +//! a UTF-8 string ready to be written as a `.ll` file and handed to +//! `clang`. The two entry points are [`emit_ir`] (single module) and +//! [`lower_workspace`] (multi-module); both share the same mangling +//! scheme and ABI. +//! //! Strategy: we generate LLVM IR as a string, write it as `.ll`, and //! link it with `clang`. No binding to a specific libllvm version. //! @@ -13,47 +21,113 @@ //! even for single-module programs. The old form `@ail_` is gone. //! - Global string/constant symbols are mangled per module: //! `@.str__` and `@ail__` for constant globals. -//! - The entry point remains `main` (LLVM/C ABI). The generator emits -//! `define i64 @main() { call @ail__main() ... }` as a -//! trampoline to the entry module's `main`. If missing, the build -//! fails with `MissingEntryMain`. +//! - The entry point remains `main` (LLVM/C ABI). [`lower_workspace`] +//! emits `define i64 @main() { call @ail__main() ... }` +//! as a trampoline to the entry module's `main`. If missing, the +//! build fails with [`CodegenError::MissingEntryMain`]. //! - `source_filename` appears exactly once at the top, with //! `.ail` as value (per workspace). +//! +//! **Precondition.** Neither [`emit_ir`] nor [`lower_workspace`] runs +//! the typechecker. Callers must have run `ailang_check::check_module` +//! (or `check_workspace`) first; codegen will panic or emit malformed +//! IR if invariants the checker enforces (resolved metavars, declared +//! effects, ctor arity) are violated. use ailang_core::ast::*; use ailang_core::Workspace; use std::collections::{BTreeMap, BTreeSet}; +/// Failure modes of [`emit_ir`] / [`lower_workspace`]. +/// +/// Most variants signal a compiler invariant violation rather than a +/// user-facing diagnostic — by the time a module reaches codegen the +/// typechecker has already accepted it. The exceptions are +/// [`CodegenError::MissingEntryMain`] (a workspace-level shape check +/// that the typechecker doesn't enforce) and the wrapping variants +/// [`CodegenError::Def`] / [`CodegenError::InModule`] which add path +/// context to an inner error. #[derive(Debug, thiserror::Error)] pub enum CodegenError { + /// Wraps an inner error with the name of the def being lowered. + /// Attached by the per-def lowering loop in [`lower_workspace`] so + /// the failing definition is named in the message even when the + /// underlying error is structural (e.g. an [`CodegenError::Internal`] + /// from deep inside `lower_term`). #[error("def `{0}`: {1}")] Def(String, Box), + /// Wraps an inner error with the name of the module being lowered. + /// Attached by [`lower_workspace`]'s per-module loop so multi-module + /// builds report which module failed without requiring the caller + /// to thread a module name through every call site. #[error("module `{0}`: {1}")] InModule(String, Box), + /// `llvm_type` was asked to lower an AILang [`Type`] it does not + /// know how to represent. In the MVP this fires for an unresolved + /// rigid `Type::Var` reaching codegen (a substitution bug; see + /// Iter 13b notes in `DESIGN.md`) or for any non-`Con`/`Fn`/`Var` + /// shape that has not yet been wired through. #[error("unsupported type: {0}")] UnsupportedType(String), + /// A `Term::Var { name }` could not be resolved against the local + /// SSA stack, the current module's top-level fns, or a qualified + /// import. A correctly type-checked module never produces this; if + /// it does, the typechecker and the codegen-side resolver have + /// drifted out of sync. #[error("unknown variable: `{0}`")] UnknownVar(String), + /// A `Def::Fn` was reached whose `ty` is not a `Type::Fn`. The + /// typechecker ([`ailang_check::CheckError::FnTypeRequired`]) should + /// have rejected this case before us; emit_fn re-checks defensively + /// because a stale typechecker contract would otherwise produce + /// malformed IR. #[error("expected fn type, got {0}")] NotFnType(String), + /// The entry module of the workspace has no `main : () -> Unit !IO` + /// def, so [`lower_workspace`] cannot emit the C-ABI trampoline. + /// This is a workspace-level shape requirement that the typechecker + /// does not enforce (a library module is well-typed without a main), + /// so it surfaces here instead. #[error("entry module `{0}` has no `main` def")] MissingEntryMain(String), + /// Catch-all for codegen-side invariant violations: missing + /// ctor entry, lambda environment shape mismatch, mono-queue + /// inconsistency, etc. The string carries the precise diagnostic; + /// a user-facing build never produces this if the workspace + /// type-checks cleanly. #[error("internal: {0}")] Internal(String), } type Result = std::result::Result; -/// Lowers a single module. Backwards compatibility for tests / CLI calls -/// that want to operate without a workspace. Internally a trivial -/// workspace with just this module is built, so the mangling scheme -/// stays consistent. +/// Single-module entry point. Lowers `m` to a `.ll` string with `m` +/// itself as the entry module. Returns the full LLVM IR text, ready to +/// be written to disk and handed to `clang`. +/// +/// Used by tests, by `ail emit-ir`, and by `ail build`/`run` whenever +/// the input is a single `.ail.json` file rather than a workspace. +/// Internally builds a trivial [`Workspace`] containing only `m` and +/// delegates to [`lower_workspace`] — the mangling scheme, +/// `source_filename`, and the `@main` trampoline are therefore +/// identical between the two entry points. +/// +/// **Precondition.** `m` must already type-check. This function does +/// **not** call `ailang_check`; passing a module with unresolved +/// metavars, undeclared effects, or arity mismatches will produce +/// either a [`CodegenError`] or malformed IR. For any input that came +/// from disk, run `ailang_check::check_module(m)` first and only call +/// `emit_ir` when the diagnostic list is empty. +/// +/// Use [`lower_workspace`] instead when the program spans multiple +/// modules (cross-module calls, transitive imports) — `emit_ir` is the +/// short-cut for the single-file demo case. pub fn emit_ir(m: &Module) -> Result { let mut modules = BTreeMap::new(); modules.insert(m.name.clone(), m.clone()); @@ -65,14 +139,31 @@ pub fn emit_ir(m: &Module) -> Result { lower_workspace(&ws) } -/// Lowers a whole workspace to a `.ll` string. Module order is -/// alphabetic (BTreeMap order = deterministic). Within a module, def -/// order matches the AST. +/// Multi-module entry point. Lowers an entire [`Workspace`] (entry +/// module plus its transitive imports, as produced by +/// `ailang_core::load_workspace`) to a single `.ll` string and emits +/// the C-ABI `@main` trampoline pointing at the entry module's `main`. +/// This is what `ail build` and `ail run` call for any real +/// multi-module program. +/// +/// Module order is alphabetic (BTreeMap order = deterministic). Within +/// a module, def order matches the AST. /// /// Cross-module calls: `Term::Var { name }` with exactly one dot /// (`.`) is resolved via the calling module's import map /// to `@ail__`. Local var lookups (no dot) stay /// stack locals or local top-level defs of the current module. +/// +/// **Precondition.** Every module in `ws.modules` must already +/// type-check (`ailang_check::check_workspace(ws)` returns no errors). +/// `lower_workspace` does **not** invoke the typechecker itself; +/// running it on an unchecked workspace is a caller bug. Beyond +/// type-checking, this function additionally requires that the entry +/// module declares `main : () -> Unit !IO` — otherwise it returns +/// [`CodegenError::MissingEntryMain`]. +/// +/// Use [`emit_ir`] for the single-file shortcut when there are no +/// imports. pub fn lower_workspace(ws: &Workspace) -> Result { let mut header = String::new(); let mut body = String::new(); diff --git a/docs/JOURNAL.md b/docs/JOURNAL.md index 52e4c9f..955b6dd 100644 --- a/docs/JOURNAL.md +++ b/docs/JOURNAL.md @@ -1312,3 +1312,98 @@ CLI) is the natural next polish iter; 14a (`List a` rewrite of `list_map`) remains the next-feature default. Auto-mode is on, so I'll continue into 13f directly unless context budget pressures a switch. + +## Iter 13f — rustdoc polish for `ailang-codegen` + `ail` CLI + +Combined docwriter mission. Codegen has a small public +surface (only 3 top-level `pub` items: `CodegenError` enum + +7 variants, `emit_ir`, `lower_workspace`); the CLI is a +binary with zero `pub` items, so the only useful rustdoc is +the module header. One agent run, both files. + +What landed (148 LOC of doc additions, no non-doc lines +changed, verified by filtering the diff): + +- `ailang-codegen` crate root got intra-doc-link upgrades + (`[`emit_ir`]`, `[`lower_workspace`]`, + `[`CodegenError::MissingEntryMain`]`) and a precondition + sentence — both entry points assume their input has already + passed the typechecker; codegen does not call the checker + itself. +- Every `CodegenError` variant got a `///` string naming the + AST term/condition that triggers it. Same shape as + `CheckError` post-13e, so the two error enums now read + similarly and a reader can grep across them. `Internal` is + flagged in its doc as a catch-all that covers ~30 + invariant-violation sites. +- `emit_ir` and `lower_workspace` now make the + single-vs-multi-module split explicit and cross-link to + each other. +- `ail/main.rs` module header expanded from a 5-line stub to + a full subcommand list with one-liners (`manifest`, + `render`, `describe`, `deps`, `check`, `emit-ir`, `build`, + `run`, `builtins`, `diff`, `workspace`), the `clang`-on-PATH + prerequisite for `build`/`run`, the design-intent paragraph + about each subcommand being narrowly scoped for LLM + consumption (already partly there), and an explicit "no + `pub` items, `--help` text comes from clap" note for anyone + who lands here from rustdoc. + +**Findings reported** (judgement deferred to me): + +- `CodegenError::Internal(String)` is a single opaque catch-all + for ~30 distinct invariant-violation sites (mono-queue + desync, ctor-index miss, lambda-env shape, ...). Tests can + only substring-match on it. **Not fixing**: splitting is a + test-ergonomics decision, not a doc one. Worth raising the + next time codegen tests get a serious rewrite. +- `emit_ir` synthesises an internal `Workspace` with + `root_dir = "."`. No codegen path reads `root_dir` today, so + this is harmless; if a future feature reaches `root_dir` + from codegen, the assumption surfaces. **Not fixing**: the + agent flagged it correctly as "would change behaviour, out + of scope". + +**Process note: brief drift caught by the agent.** I told the +docwriter the CLI had nine subcommands; it found eleven +(`Deps` and `Diff` were missing from my brief, which was +written off a `head -40` of the source). Agent silently +corrected and flagged the drift in its findings. Useful +counter-pressure to the orchestrator pattern: my survey was +sloppy and the agent did not propagate the sloppiness into +the doc. This is one of the things sub-agents are good at and +why I keep delegating even on small jobs. + +**Tests:** 64/64 unit + e2e (unchanged), 3 ignored doctests +(unchanged). `cargo doc --no-deps`: 0 warnings. Also verified +under `RUSTDOCFLAGS='-D rustdoc::broken_intra_doc_links'` per +agent report. `cargo build --workspace` green. `cargo test +--workspace` green. + +**Workspace-wide rustdoc invariant achieved.** All four +crates (`ailang-core`, `ailang-check`, `ailang-codegen`, +`ail`) now have: +- crate-root `//!` that names ownership, position in + pipeline, and entry points; +- module-root `//!` on every file with non-trivial content; +- `///` on every public item (struct, enum, variant, fn, + field, const) that names the contract, not just the type; +- intra-doc links wherever prose previously referred to + another item by name. + +DESIGN.md item 6 ("rustdoc cleanliness") is now load-bearing +across the whole workspace, not just `core`. The +`ailang-docwriter` agent's job from here is **maintenance**: +run after any iter that adds public surface, not full sweeps. + +**Next.** 14a is now unblocked: write a polymorphic `list_map` +that uses `List a` (Iter-13a parameterised ADTs) and `Maybe a`, +then extend an existing demo program (`hello_print` or one of +the dogfood sources) to call it end-to-end. That exercises +the parameterised-ADT pipeline through type-check, codegen, +and runtime — the missing piece in 13a/b/c was that the +feature shipped but no real program used it. If context +budget at the start of 14a is tight, alternative is 14b +(GC/arena scaffolding) or 14c (poly fn as value); both have +real design questions that need an orchestrator design pass +first, so 14a stays the default.