Iter 14c: ailang-surface ships — form (A) parser + pretty-printer

Strictly additive new crate per Decision 6 architectural pin.
JSON-AST stays the source of truth; ailang-surface is one
producer/consumer of ailang_core::ast::Module values. ailang-check
and ailang-codegen unchanged.

Crate contents:
- src/lex.rs (~264 LOC): 3-rule lexical core. Whitespace and parens
  delimit tokens; semicolon to EOL is comment; first-character
  classifier (digit -> int, " -> string, else -> ident).
- src/parse.rs (~1041 LOC): hand-written recursive descent. One Rust
  fn per EBNF production. No parser-combinator dep.
- src/print.rs (~371 LOC): deterministic pretty-printer. Round-trip
  contract with parse() is the surface's correctness gate.
- tests/round_trip.rs (~128 LOC): integration test runs every
  examples/*.ail.json fixture through print -> parse -> canonical
  JSON, asserts canonical-byte equality with the original.

Two AST-driven form widenings beyond the 14b sketch (both folded
into DESIGN.md Decision 6):
- lam-term carries (typed name type) params, ret type, and
  optional effects (Term::Lam has parallel param_tys/ret_ty/
  effects fields).
- import-clause admits (import name (as alias)?) (Import.alias
  is Option<String>).

Production count ~28, under 30-rule budget. Constraint 1
(formalisable for foreign LLM) intact.

Verification:
- cargo build --workspace green.
- cargo test --workspace: 76 tests green (was 64; +9 surface unit,
  +2 round-trip integration). All 17 fixtures round-trip
  byte-identical at canonical level. 3 hand-written .ailx
  exhibits parse to canonical JSON identical to their .ail.json
  siblings.
- cargo doc --no-deps zero warnings (DESIGN.md item 6 invariant).

Manual smoke test (ail parse → ail run): hello, box,
list_map_poly all produce expected output through the form-(A)
authoring lane end-to-end.

CLI: ail parse <file.ailx> [-o <file.ail.json>]. .ail.json
remains a first-class input to every existing subcommand.

Plan 14d: stdlib (std_list.ailx with length/filter/fold/concat/
reverse/head/tail), authored in form (A) from day one.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 16:22:14 +02:00
parent 5e8342df02
commit 706f90bacd
13 changed files with 2065 additions and 4 deletions
+38
View File
@@ -391,6 +391,44 @@ on the shelf for a future iter only if both fail.
`build`, `run`, `manifest`, `deps`, `diff`, `workspace`,
`builtins`) keeps its current `.ail.json` interface.
### Form refinements during Iter 14c implementation
Two productions in the original 14b sketch had to be widened
during implementation to round-trip the existing AST faithfully.
Captured here for the spec record:
1. **`lam-term` carries types and effects.** The AST's `Term::Lam`
stores parallel `params`, `param_tys`, `ret_ty`, and `effects`
fields. The 14b sketch had only names. The implemented form is
```
lam-term ::= "(" "lam" "(" "params" typed-param* ")"
"(" "ret" type ")"
effects-clause? body-attr ")"
typed-param ::= "(" "typed" ident type ")"
```
This keeps the no-precedence / one-construct-per-token-list
invariants and adds no new lexical rules.
2. **`import-clause` admits an optional alias.** The AST's
`Import.alias` is `Option<String>`; the original sketch only
supported the `None` case. The implemented form is
```
import-clause ::= "(" "import" ident ("as" ident)? ")"
```
`as` is a bare ident token in this position; no special lexical
rule is needed.
Neither change extends the grammar's rule budget meaningfully:
the 30-production ceiling of constraint 1 is intact (the parser
implements ~28 named productions). All 17 `examples/*.ail.json`
fixtures round-trip identically through `print → parse → canonical
JSON`; the three hand-written `.ailx` exhibits parse to canonical
JSON identical to their corresponding `.ail.json` files.
## Mangling scheme (Iter 5c)
All AILang functions are mangled to `@ail_<module>_<def>` — even in the
+118
View File
@@ -1627,4 +1627,122 @@ with `length`, `filter`, `fold`, `concat`, `reverse`, `head`,
for the still-young parameterised-ADT pipeline. Written in the
new surface from day one — no JSON authoring of stdlib.
## Iter 14c — `ailang-surface` parser + pretty-printer ships
Implements Decision 6's form (A). New crate `ailang-surface`
(~1843 LOC across `lex.rs`, `parse.rs`, `print.rs`, lib root,
plus tests) ships as a strictly additive producer of
`ailang-core::ast::Module` values. `ailang-check` and
`ailang-codegen` were not modified — projection-agnostic, as
the architectural pin requires.
**Implementer dispatch went clean.** Brief gave the EBNF, the
fixtures to round-trip, the lexer rule, and the architectural
constraints. Implementer hand-wrote a recursive-descent parser
(one Rust fn per EBNF production), a deterministic pretty-
printer, an integration test that runs the round-trip gate on
every fixture, and the `ail parse` CLI subcommand. No
parser-combinator dependency. No AST-shape changes.
**Two AST-driven form refinements** that were not in the 14b
sketch (both folded into DESIGN.md Decision 6 by the
implementer before commit):
1. `lam-term` had to carry `param_tys`, `ret_ty`, and
`effects` because the AST's `Term::Lam` stores parallel
typed-param data. Production became `(lam (params (typed
x Int) ...) (ret T) (effects ...) (body ...))`. Same
shape-style as the rest of the form; no new lex rules.
2. `import-clause` had to admit `Option<String>` aliases.
Production became `(import name (as alias)?)` with `as`
as a bare ident token. No new lex rules.
The 14b 30-production budget held: implementer reports ~28
named productions in the parser. Constraint 1 (formalisable
for foreign LLM) intact.
**Verification gates, all green:**
- `cargo build --workspace`: 0 warnings, finished.
- `cargo test --workspace`: 76 tests pass (was 64; +9 unit
tests in `ailang-surface`, +2 integration tests in
`tests/round_trip.rs`, +1 e2e regression preserved). All
17 `examples/*.ail.json` fixtures round-trip
byte-identical through `print → parse → canonical JSON`;
3 hand-written `.ailx` exhibits parse to canonical JSON
byte-identical to their `.ail.json` siblings.
- `cargo doc --no-deps`: 0 warnings (workspace invariant
from 13d/e/f preserved; new crate's rustdoc landed
correctly with crate-root `//!` plus all `pub` items
documented).
**Manual smoke test (orchestrator-side after agent reported
done):** `ail parse <file.ailx> -o <file.ail.json>` followed
by `ail run <file.ail.json>` for all three exhibits:
```
hello.ailx → "Hello, AILang."
box.ailx → "42"
list_map_poly.ailx → "2\n3\n4"
```
End-to-end pipeline form (A) → AST → typecheck → codegen →
clang → binary works on all three.
**Important non-issue.** `examples/*.ail.json` fixtures on
disk are hand-formatted JSON with non-canonical key order;
the parser produces canonical (lex-sorted) JSON. Diff at
the file-byte level is not zero. Diff at the canonical-byte
level is zero — which is the only thing that matters per
Decision 1, since hashing uses canonical form. The
round-trip test gates on canonical bytes, not file bytes.
This is correct behaviour; flagged here so a future reader
who runs `diff` doesn't think the surface is broken.
**Files created:**
- `crates/ailang-surface/Cargo.toml`
- `crates/ailang-surface/src/lib.rs` (~40 LOC, rustdoc heavy)
- `crates/ailang-surface/src/lex.rs` (~264 LOC)
- `crates/ailang-surface/src/parse.rs` (~1041 LOC, one fn
per production)
- `crates/ailang-surface/src/print.rs` (~371 LOC)
- `crates/ailang-surface/tests/round_trip.rs` (~128 LOC)
**Files modified:**
- `Cargo.toml` (workspace) — `ailang-surface` member +
workspace dep.
- `Cargo.lock` — refresh.
- `crates/ail/Cargo.toml` — `ailang-surface` dep.
- `crates/ail/src/main.rs` — new `Parse { path, output }`
subcommand (~36 LOC).
- `docs/DESIGN.md` — form refinements appendix to
Decision 6.
- `examples/list_map_poly.ailx` — implementer added doc
strings to match the JSON original (was a 14b design
exhibit, not byte-aligned).
**Known debt:** none reported. `ailang-check` /
`ailang-codegen` untouched per the architectural pin.
**Plan 14d (next, queued).** With form (A) now ergonomic
and round-trip-verified, the std-lib path opens up. First
target: `examples/std/std_list.ailx` with `length`,
`filter`, `fold` (left and right), `concat`, `reverse`,
`head`, `tail`. Each combinator a fresh test vector for
the parameterised-ADT pipeline (which 14a opened end-to-
end). Authored in form (A); the resulting `.ail.json` is
what tests consume. If 14d surfaces more codegen bugs in
the parameterised-ADT path (it likely will — 14a found
one already), debugger handles them inline.
The 14b/c form-A hypothesis has held under the empirical
test of round-tripping every existing fixture. The
documented rollback to form (C) is now off the table for
this iter cycle, though the architectural pin keeps it
open for future replacement of `ailang-surface` should
the form prove inadequate at stdlib scale.