# nullary-app.1 — Accept `(app f)` as the canonical zero-arg call form > **No parent spec.** Gitea issue #12 carries the resolved design > fork verbatim; two prior fieldtest specs > (`docs/specs/2026-05-15-fieldtest-mut-local.md` F3, > `docs/specs/2026-05-18-fieldtest-loop-recur.md` spec_gap) are the > LLM-natural-shape evidence. User accepted `(app f)` + Ctor-style > serde convention on 2026-05-20. > > **For agentic workers:** REQUIRED SUB-SKILL: use `skills/implement` > to run this plan. **Goal:** Lift the surface parser's "at least one argument" rule on `app-term` (and `tail-app-term`) so a niladic function can be called as `(app f)`. Round-trip and downstream stages already accept empty `args` mechanically — typechecker's arity check is `args.len() == params.len()` (passes for `0 == 0`), codegen's `args.iter().zip(...)` loop is a no-op for empty args, surface printer's arg-emit loop prints nothing for empty args. The fix is the parser guard plus one serde-attribute edit on `Term::App.args` to mirror `Term::Ctor.args` (read-tolerant when `args` is absent in canonical JSON). **Doc-honesty side-fix:** the existing comment on `Term::Ctor` in `design/contracts/data-model.md:130-131` claims "`args` omitted when empty", which is *factually false* for the current code — `Ctor.args` has `#[serde(default)]` (read-tolerant) but NO `skip_serializing_if`, so writes always emit `"args":[]`. Fix the comment to describe actual behaviour. This is `design/contracts/honesty-rule.md` enforcement folded into the same iter (the discovery happened while researching this iter's serde change, and a stale comment in the same jsonc block we're modifying must not be left behind). **Architecture:** Three surface-layer edits + one schema-doc edit + one new E2E test (which is also the RED-then-GREEN pin for the parser change). No typechecker, codegen, or runtime changes — every layer below the parser already handles empty `args` correctly today. **Tech Stack:** `ailang-surface` (parser), `ailang-core` (AST serde), `design/contracts/data-model.md` (schema doc), `crates/ail` (E2E test). **Files this plan creates or modifies:** - Create: `examples/nullary_app_smoke.ail` — single nullary user fn `greet : fn() -> Unit !IO` called as `(app greet)`. - Create: `crates/ail/tests/nullary_app_e2e.rs` — builds and runs the fixture; asserts `"hello\n"` stdout. - Modify: `crates/ailang-surface/src/parse.rs:1322-1342` — drop the 4-line empty-args guard from `parse_app_body`; the doc comment on `parse_app_body` changes from "callee + 1+ args + closing `)`" to "callee + 0+ args + closing `)`". The grammar comment near the top of the file (`parse.rs:48`) changes `term+` to `term*`. - Modify: `crates/ailang-core/src/ast.rs:419-425` — annotate `Term::App.args` with `#[serde(default)]`, mirroring `Term::Ctor.args` at line 472-477. `skip_serializing_if` is NOT added (mirror Ctor's *actual* attrs, not the doc claim). - Modify: `design/contracts/data-model.md:112` — add an inline comment to the `app` jsonc shape that `args` MAY be empty (e.g. nullary call `(app f)`). - Modify: `design/contracts/data-model.md:130-131` — rewrite the misleading "args omitted when empty" comment on the `ctor` jsonc shape so it describes the actual write/read behaviour: write always emits `"args":[]`; read tolerates absent `args`. --- ## Task 1: RED — fixture + E2E test for nullary `(app f)` **Files:** - Create: `examples/nullary_app_smoke.ail` - Create: `crates/ail/tests/nullary_app_e2e.rs` The RED test compile-blocks NOTHING (it is a new file using public CLI surface). It fails at runtime because `ail build` returns a parse error from `parse_app_body`'s "expected at least one argument" guard on the `(app greet)` call. Watch the failure carry that exact error string; that confirms the RED hits the targeted symptom. - [ ] **Step 1.1: Write the example fixture** Create `examples/nullary_app_smoke.ail`: ```lisp (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\n"))) (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)))) ``` Confirm `greet` and `main` both have empty `(params)` and both type to `fn() !IO -> Unit`. The fixture exercises BOTH a nullary fn definition AND a nullary call to it. - [ ] **Step 1.2: Write the RED E2E test** Create `crates/ail/tests/nullary_app_e2e.rs`: ```rust //! 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).", ); } ``` - [ ] **Step 1.3: Confirm RED** Run: `cargo test --quiet -p ail --test nullary_app_e2e` Expected: FAIL. The failure must mention "expected at least one argument" in the `ail build` stderr (visible because `build_and_run` asserts `status.success()` and the build failure propagates the parser diagnostic). If the failure mode is anything else — wrong stdout instead of build failure, panic from a missing file, etc. — STOP and re-read; the RED is not pinning the targeted symptom. --- ## Task 2: GREEN — drop the empty-args guard in `parse_app_body` **Files:** - Modify: `crates/ailang-surface/src/parse.rs:48` - Modify: `crates/ailang-surface/src/parse.rs:1315-1342` - [ ] **Step 2.1: Update the grammar comment near the file top** `crates/ailang-surface/src/parse.rs:48` currently reads: ``` //! app-term ::= "(" "app" term term+ ")" ``` Change `term+` to `term*`. Also update the next line (the `tail-app-term` grammar) to match — that line currently mirrors the same `term+`. Re-verify by re-reading lines 47-50 to ensure both productions now read `term*` (zero or more args). - [ ] **Step 2.2: Update the `parse_app_body` doc comment** `crates/ailang-surface/src/parse.rs:1315-1316` reads: ```rust /// Body shared by [`Self::parse_app`] and [`Self::parse_tail_app`]: /// callee + 1+ args + closing `)`. ``` Change `1+ args` to `0+ args`. - [ ] **Step 2.3: Drop the empty-args guard** Delete `crates/ailang-surface/src/parse.rs:1323-1331` — the entire guard block: ```rust 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, }); } let mut args = Vec::new(); ``` becomes: ```rust let callee = self.parse_term()?; // 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(); ``` - [ ] **Step 2.4: Confirm Task 1's RED now flips to GREEN** Run: `cargo test --quiet -p ail --test nullary_app_e2e` Expected: PASS with the `"hello\n"` stdout assertion. --- ## Task 3: GREEN — serde `default` on `Term::App.args` **Files:** - Modify: `crates/ailang-core/src/ast.rs:419-425` - [ ] **Step 3.1: Annotate `App.args` with `#[serde(default)]`** `crates/ailang-core/src/ast.rs:419-425` currently reads: ```rust App { #[serde(rename = "fn")] callee: Box, args: Vec, #[serde(default, skip_serializing_if = "is_false")] tail: bool, }, ``` Add `#[serde(default)]` to `args` so a canonical JSON document that omits the `args` key parses to an empty `Vec`. Do NOT add `skip_serializing_if = "Vec::is_empty"` — Ctor's actual write behaviour always emits `"args":[]`; this iter mirrors that exactly: ```rust App { #[serde(rename = "fn")] callee: Box, #[serde(default)] args: Vec, #[serde(default, skip_serializing_if = "is_false")] tail: bool, }, ``` The doc comment on `App` (lines 411-418) does not need an edit — the variant docstring is about semantics, not serialisation, and `tail`'s comment already covers the "additive variants must hash-stably" pattern. - [ ] **Step 3.2: Verify the existing corpus still serialises identically** Run: `cargo test --workspace --quiet` Expected: every existing test still PASS. The new `#[serde(default)]` is read-tolerance-only — it changes deserialisation behaviour for documents missing `args`, but every existing `.ail.json` fixture has a non-empty `args` (verified at plan-write time: `grep -rn '"args":\[\]' examples/*.ail.json` returns zero matches). Write behaviour is unchanged. Canonical-byte hashes of every existing fixture are therefore unchanged. --- ## Task 4: Doc-honesty fix — `data-model.md` `ctor` and `app` comments **Files:** - Modify: `design/contracts/data-model.md:110-131` This task is `design/contracts/honesty-rule.md` enforcement — the prose-authoritative contract must describe present-state behaviour, not aspirational behaviour. The current `Ctor` comment is aspirational (the omit-when-empty rule is not implemented), and the `App` jsonc shape benefits from an explicit note now that nullary calls are legal. - [ ] **Step 4.1: Add an empty-args note to the `app` shape** `design/contracts/data-model.md:110-112` currently reads: ```jsonc // fn application; tail flag triggers musttail under codegen. // `tail` is omitted when false (hash-stable when omitted). { "t": "app", "fn": Term, "args": [Term...], "tail": false } ``` Extend the comment block above the jsonc to mention that `args` may be empty (nullary call). New text: ```jsonc // 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 } ``` - [ ] **Step 4.2: Rewrite the misleading `ctor` comment** `design/contracts/data-model.md:130-131` currently reads: ```jsonc // Ctor application; `args` omitted when empty. { "t": "ctor", "type": "", "ctor": "", "args": [Term...] } ``` Replace with the actual behaviour: ```jsonc // 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": "", "ctor": "", "args": [Term...] } ``` - [ ] **Step 4.3: Verify drift-pin still green** Run: `cargo test --quiet -p ailang-core --test design_schema_drift` Expected: PASS. The drift test scans only jsonc-fenced blocks (per bug #10's GREEN fix); the anchor strings `"t": "app"` and `"t": "ctor"` remain inside the same jsonc block and are still found. The comment changes do NOT move any schema anchor. --- ## Task 5: Workspace-wide sanity **Files:** none modified. - [ ] **Step 5.1: Full workspace test** Run: `cargo test --workspace --quiet` Expected: PASS across all crates. No regressions; new `nullary_app_e2e` test in green state from Task 2.4. - [ ] **Step 5.2: Drift-pin re-run for honesty-rule compliance** Run: `cargo test --quiet -p ailang-core --test design_index_pin --test design_schema_drift` Expected: PASS. Both drift tests are jsonc-fenced-only after bug #10's GREEN; the present iter touches doc-comments inside an existing jsonc block but does not move any anchor. - [ ] **Step 5.3: Round-trip invariant** Run: `cargo test --quiet -p ailang-surface --test round_trip` Expected: PASS. The new fixture `examples/nullary_app_smoke.ail` has no `.ail.json` counterpart (none authored), so it falls under the test's "parse-only" branch. This is correct: the canonical form a Form-A author would produce for nullary `(app f)` is `{"t":"app","fn":{"t":"var","name":"f"}, "args":[]}` — write-emit always carries `"args":[]`. A future iter may author the JSON counterpart explicitly if a hash-stable canonical fixture is wanted; not required for this iter.