diff --git a/docs/plans/nullary-app.1.md b/docs/plans/nullary-app.1.md new file mode 100644 index 0000000..999979d --- /dev/null +++ b/docs/plans/nullary-app.1.md @@ -0,0 +1,403 @@ +# 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.