Iter 13f: rustdoc polish for ailang-codegen + ail CLI

Third docwriter mission (combined). Pure rustdoc additions, no
API or behaviour change.

ailang-codegen (113 LOC of doc):
- Crate root: intra-doc links (emit_ir, lower_workspace,
  CodegenError::MissingEntryMain) + precondition note (both
  entry points assume type-checked input).
- /// on CodegenError + every variant, naming AST trigger.
- /// on emit_ir and lower_workspace (single vs multi-module
  split, cross-linked).

ail CLI (49 LOC of doc):
- Module-level //! expanded from 5 lines to full subcommand
  list with one-liners (11 subcommands verified against Cmd
  enum), clang-on-PATH note, design-intent paragraph.

Verification: cargo doc --no-deps zero warnings (also under
RUSTDOCFLAGS='-D rustdoc::broken_intra_doc_links'); build green;
tests 64/64 + 3 ignored doctests green. Diff is 100% doc lines
(verified by filtering).

Findings (not fixed; orchestrator-deferred):
- CodegenError::Internal is one catch-all variant for ~30
  invariant-violation sites; splitting would help test
  ergonomics but is out of doc scope.
- emit_ir synthesises a Workspace with root_dir="."; harmless
  today, surfaces if codegen ever reads root_dir.

Process note: my brief said the CLI had 9 subcommands; agent
found and documented 11. Useful counter-pressure on orchestrator
sloppiness — recorded in JOURNAL.

Workspace-wide rustdoc invariant (DESIGN.md item 6) now
load-bearing across all four crates. Docwriter shifts from
sweep to maintenance mode going forward.

Next: 14a (polymorphic list_map using Iter-13a parameterised
ADTs) is unblocked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 15:20:17 +02:00
parent d8727d5bdb
commit 1918fbee7f
3 changed files with 243 additions and 14 deletions
+46 -3
View File
@@ -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};
+102 -11
View File
@@ -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_<def>` is gone.
//! - Global string/constant symbols are mangled per module:
//! `@.str_<module>_<idx>` and `@ail_<module>_<def>` for constant globals.
//! - The entry point remains `main` (LLVM/C ABI). The generator emits
//! `define i64 @main() { call @ail_<entry-module>_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_<entry-module>_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
//! `<entry-module>.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<CodegenError>),
/// 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<CodegenError>),
/// `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<T> = std::result::Result<T, CodegenError>;
/// 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<String> {
let mut modules = BTreeMap::new();
modules.insert(m.name.clone(), m.clone());
@@ -65,14 +139,31 @@ pub fn emit_ir(m: &Module) -> Result<String> {
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
/// (`<prefix>.<def>`) is resolved via the calling module's import map
/// to `@ail_<actual_module>_<def>`. 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<String> {
let mut header = String::new();
let mut body = String::new();
+95
View File
@@ -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.