Files
AILang/docs/plans/0097-nullary-app.1.md
T
Brummel 832375f2ac convention: counter-prefix file naming across docs/specs/, docs/plans/, design/contracts/, design/models/
All 176 files in the four accumulating directories now use a
zero-padded 4-digit counter prefix that reflects creation order
(`NNNN-slug.md`). The counter is assigned per directory in strict
git-log creation order; ties broken alphabetically by original name.
The old `YYYY-MM-DD-` prefix on docs/specs/ and docs/plans/ files is
dropped — the date is recoverable from git log and the counter
carries the ordering.

A file's counter is stable for the life of the file: never reassigned,
never reused, never compacted. Deleted files retire their counter;
subsequent files do not fill the gap. This is the property that lets
cross-references stay literal — refs use the full filename including
the counter (`design/contracts/0007-honesty-rule.md`) so they grep
cleanly and resolve directly without a glob step.

313 cross-references updated across .md/.rs/.toml/.c/.json files
(test pins, include_str! paths, design-INDEX entries, baseline notes,
runtime C comments, inter-contract markdown links incl. bare basename
and `../models/foo.md` forms).

CLAUDE.md gets a new "File-naming convention" section spelling out
the rule and rationale. skills/brainstorm/SKILL.md and
skills/planner/SKILL.md updated so new spec/plan creation produces
counter-prefixed names from the start.

The full test suite (cargo test --workspace) passes.
2026-05-28 13:31:31 +02:00

15 KiB

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/0030-fieldtest-mut-local.md F3, docs/specs/0035-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/0002-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/0007-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/0002-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/0002-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/0002-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:

    (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:

    //! 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/0030-fieldtest-mut-local.md` F3 and
    //! `docs/specs/0035-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:

    /// 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:

            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:

            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:

        App {
            #[serde(rename = "fn")]
            callee: Box<Term>,
            args: Vec<Term>,
            #[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:

        App {
            #[serde(rename = "fn")]
            callee: Box<Term>,
            #[serde(default)]
            args: Vec<Term>,
            #[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/0002-data-model.md:110-131

This task is design/contracts/0007-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/0002-data-model.md:110-112 currently reads:

    // 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:

    // 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/0002-data-model.md:130-131 currently reads:

    // Ctor application; `args` omitted when empty.
    { "t": "ctor", "type": "<id>", "ctor": "<id>", "args": [Term...] }
    

    Replace with the actual behaviour:

    // 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...] }
    
  • 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.