iter nullary-app.1 (DONE 5/5): accept (app f) as canonical zero-arg call

Resolves Gitea #12 — the design fork from the 2026-05-20 /boss
session, surfaced independently by two prior fieldtests
(mut-local F3 + loop-recur `run_forever`).

Mechanics, layer by layer:

  Parser — `crates/ailang-surface/src/parse.rs:1322-1331` drops
    the 4-line `expected at least one argument` guard in
    `parse_app_body`. Grammar comments at file top change `term+`
    to `term*` on both `app-term` and `tail-app-term`; doc comment
    on `parse_app_body` changes `1+ args` to `0+ args`. Inline
    comment cites #12 and notes which layers below already accept
    empty args.

  Serde — `crates/ailang-core/src/ast.rs:419-425` annotates
    `Term::App.args` with `#[serde(default)]`, mirroring
    `Term::Ctor.args`'s *actual* attrs verbatim (no
    `skip_serializing_if`). Write behaviour unchanged — canonical
    JSON still emits `"args":[]` for nullary calls; only read
    behaviour gains tolerance for the `args` key being absent.
    Verified hash-impact-free at plan time:
    `grep -rn '"args":\[\]' examples/*.ail.json` returns zero
    matches, so no existing fixture's canonical bytes shift.

  Doc-honesty — `design/contracts/data-model.md`:
    (a) the `app` jsonc shape gains an explicit "args may be
        empty / read-tolerant" note;
    (b) the existing `ctor` comment claiming "args omitted when
        empty" was factually false (Ctor.args has no
        `skip_serializing_if`; a serde probe at plan time
        confirmed writes always emit `"args":[]`). Rewritten to
        describe the actual write/read asymmetry, with a
        cross-reference to the new `app` note.

  E2E pin — `crates/ail/tests/nullary_app_e2e.rs` +
    `examples/nullary_app_smoke.ail`. Defines `greet : fn()
    -> Unit !IO` and calls it as `(app greet)`; asserts stdout
    `"hello\n"`. RED→GREEN pin for the parser change AND the
    milestone-protecting E2E for nullary call surface going
    forward. Fixture literal is `"hello"` (no `\n`) because
    `io/print_str` lowers via `puts` which appends a newline —
    the plan body had a `"hello\n"` literal which would have
    yielded `"hello\n\n"`; the implementer caught and aligned to
    the canonical pattern in `examples/hello.ail` while writing
    the fixture, fix scoped to that single file.

Layers below parser untouched: typechecker's arity check at
`crates/ailang-check/src/lib.rs:3300` is `args.len() !=
params.len()` which is `0 != 0 → false` for nullary; codegen's
`args.iter().zip(sig.params.iter())` at
`crates/ailang-codegen/src/lib.rs:2410` is an empty loop;
LLVM `call @ail_<mod>_<name>()` with empty arglist is valid;
surface printer's arg-emit loop at
`crates/ailang-surface/src/print.rs:430-434` writes nothing for
empty args, producing exactly `(app f)`.

Verification: full workspace `cargo test --workspace --quiet`
green (0 failed across all crates); drift pins
`design_index_pin 5/5` + `design_schema_drift 8/8`; round-trip
`2/2`; new `nullary_app_e2e 1/1`. Stats file:
`bench/orchestrator-stats/2026-05-20-iter-nullary-app.1.json`.

closes #12
This commit is contained in:
2026-05-20 18:57:14 +02:00
parent 7d8b3a3c10
commit 7a42989b34
6 changed files with 102 additions and 13 deletions
@@ -0,0 +1,12 @@
{
"iter_id": "nullary-app.1",
"date": "2026-05-20",
"mode": "standard",
"outcome": "DONE",
"tasks_total": 5,
"tasks_completed": 5,
"reloops_per_task": { "1": 0, "2": 0, "3": 0, "4": 0, "5": 0 },
"review_loops_spec": 0,
"review_loops_quality": 0,
"blocked_reason": null
}
+62
View File
@@ -0,0 +1,62 @@
//! Gitea #12 RED→GREEN pin — nullary `(app f)` surface acceptance.
//!
//! Pre-fix, `ail build` rejects `examples/nullary_app_smoke.ail`
//! at parse with `parse_app_body`'s "expected at least one
//! argument" error. Post-fix (parse guard dropped), the file
//! builds, runs, and prints `"hello\n"` from the nullary `greet`
//! invoked as `(app greet)`.
//!
//! Two independent fieldtest specs flagged the same gap:
//! `docs/specs/2026-05-15-fieldtest-mut-local.md` F3 and
//! `docs/specs/2026-05-18-fieldtest-loop-recur.md` spec_gap. The
//! Form-A surface refusing nullary calls had no schema backing —
//! `Term::App.args` is `[Term...]` (zero-or-more), so the parser
//! guard was an unbacked rule.
use std::path::Path;
use std::process::Command;
fn ail_bin() -> &'static str {
env!("CARGO_BIN_EXE_ail")
}
fn build_and_run(example: &str) -> String {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
let src = workspace.join("examples").join(example);
let tmp = std::env::temp_dir().join(format!(
"ailang_e2e_{}_{}",
example.replace('.', "_"),
std::process::id()
));
std::fs::create_dir_all(&tmp).unwrap();
let out = tmp.join("bin");
let status = Command::new(ail_bin())
.args(["build", src.to_str().unwrap(), "-o"])
.arg(&out)
.status()
.expect("ail build failed to run");
assert!(status.success(), "ail build failed for {example}");
let output = Command::new(&out).output().expect("execute binary");
assert!(
output.status.success(),
"binary {} exited non-zero",
out.display()
);
String::from_utf8(output.stdout).expect("stdout utf8")
}
/// A nullary user fn called as `(app greet)` builds and runs; the
/// nullary application surface form is the canonical zero-arg
/// shape under the resolution of Gitea #12.
#[test]
fn nullary_app_builds_and_runs() {
let out = build_and_run("nullary_app_smoke.ail");
assert_eq!(
out, "hello\n",
"nullary `(app greet)` must build, run, and emit `hello\\n`; \
got `{out}` — pre-fix surface parser rejects empty-args at \
`parse_app_body` with `expected at least one argument` (see \
Gitea #12).",
);
}
+1
View File
@@ -419,6 +419,7 @@ pub enum Term {
App {
#[serde(rename = "fn")]
callee: Box<Term>,
#[serde(default)]
args: Vec<Term>,
#[serde(default, skip_serializing_if = "is_false")]
tail: bool,
+8 -12
View File
@@ -45,8 +45,8 @@
//! str-lit ::= string ; string atom
//! bool-lit ::= "true" | "false"
//! unit-lit ::= "(" "lit-unit" ")"
//! app-term ::= "(" "app" term term+ ")"
//! tail-app-term ::= "(" "tail-app" term term+ ")" ; Iter 14e
//! app-term ::= "(" "app" term term* ")"
//! tail-app-term ::= "(" "tail-app" term term* ")" ; Iter 14e
//! ctor-term ::= "(" "term-ctor" ident ident term* ")"
//! match-term ::= "(" "match" term case-arm+ ")"
//! case-arm ::= "(" "case" pattern term ")"
@@ -1313,22 +1313,18 @@ impl<'a> Parser<'a> {
}
/// Body shared by [`Self::parse_app`] and [`Self::parse_tail_app`]:
/// callee + 1+ args + closing `)`.
/// callee + 0+ args + closing `)`.
fn parse_app_body(
&mut self,
tail: bool,
production: &'static str,
) -> Result<Term, ParseError> {
let callee = self.parse_term()?;
// 1+ args
if matches!(self.peek(), Some(Token { tok: Tok::RParen, .. })) {
let pos = self.peek().map(|t| t.span.start).unwrap_or(0);
return Err(ParseError::Production {
production,
message: "expected at least one argument".into(),
pos,
});
}
// 0+ args — empty arg list is the nullary call shape `(app f)`,
// resolution of Gitea #12. The schema permits empty `args` on
// `Term::App` (data-model.md: `args: [Term...]` is zero-or-more);
// typechecker, codegen, and surface printer already handle
// empty `args` cleanly today.
let mut args = Vec::new();
while !matches!(self.peek(), Some(Token { tok: Tok::RParen, .. })) {
args.push(self.parse_term()?);
+7 -1
View File
@@ -109,6 +109,9 @@ narrative — defaults, superclasses, diagnostics — lives in
// fn application; tail flag triggers musttail under codegen.
// `tail` is omitted when false (hash-stable when omitted).
// `args` may be empty: a nullary call is the surface form
// `(app f)` (resolution of Gitea #12). Read-tolerant: a JSON
// document omitting the `args` key deserialises to `[]`.
{ "t": "app", "fn": Term, "args": [Term...], "tail": false }
{ "t": "let", "name": "<id>", "value": Term, "body": Term }
@@ -127,7 +130,10 @@ narrative — defaults, superclasses, diagnostics — lives in
// `tail` triggers musttail (omitted when false).
{ "t": "do", "op": "<eff>/<op>", "args": [Term...], "tail": false }
// Ctor application; `args` omitted when empty.
// Ctor application. `args` is always emitted on write (including
// as `"args": []` for niladic ctors); reads tolerate the key being
// absent and treat it as `[]`. This mirrors the read/write
// asymmetry on `Term::App.args` (see above).
{ "t": "ctor", "type": "<id>", "ctor": "<id>", "args": [Term...] }
{ "t": "match", "scrutinee": Term, "arms": [Arm...] }
+12
View File
@@ -0,0 +1,12 @@
(module nullary_app_smoke
(fn greet
(doc "Nullary user function — no params, prints a fixed line.")
(type (fn-type (params) (ret (con Unit)) (effects IO)))
(params)
(body (do io/print_str "hello")))
(fn main
(doc "Calls the nullary `greet` as `(app greet)` — the zero-arg surface form accepted under Gitea #12.")
(type (fn-type (params) (ret (con Unit)) (effects IO)))
(params)
(body (app greet))))