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:
+46
-3
@@ -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};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user