From 7a42989b34a58572b110f33001a5c68e287d0653 Mon Sep 17 00:00:00 2001 From: Brummel Date: Wed, 20 May 2026 18:57:14 +0200 Subject: [PATCH] iter nullary-app.1 (DONE 5/5): accept `(app f)` as canonical zero-arg call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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__()` 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 --- .../2026-05-20-iter-nullary-app.1.json | 12 ++++ crates/ail/tests/nullary_app_e2e.rs | 62 +++++++++++++++++++ crates/ailang-core/src/ast.rs | 1 + crates/ailang-surface/src/parse.rs | 20 +++--- design/contracts/data-model.md | 8 ++- examples/nullary_app_smoke.ail | 12 ++++ 6 files changed, 102 insertions(+), 13 deletions(-) create mode 100644 bench/orchestrator-stats/2026-05-20-iter-nullary-app.1.json create mode 100644 crates/ail/tests/nullary_app_e2e.rs create mode 100644 examples/nullary_app_smoke.ail diff --git a/bench/orchestrator-stats/2026-05-20-iter-nullary-app.1.json b/bench/orchestrator-stats/2026-05-20-iter-nullary-app.1.json new file mode 100644 index 0000000..1afa207 --- /dev/null +++ b/bench/orchestrator-stats/2026-05-20-iter-nullary-app.1.json @@ -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 +} diff --git a/crates/ail/tests/nullary_app_e2e.rs b/crates/ail/tests/nullary_app_e2e.rs new file mode 100644 index 0000000..0a3e64b --- /dev/null +++ b/crates/ail/tests/nullary_app_e2e.rs @@ -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).", + ); +} diff --git a/crates/ailang-core/src/ast.rs b/crates/ailang-core/src/ast.rs index 6e7a148..d1e77a0 100644 --- a/crates/ailang-core/src/ast.rs +++ b/crates/ailang-core/src/ast.rs @@ -419,6 +419,7 @@ pub enum Term { App { #[serde(rename = "fn")] callee: Box, + #[serde(default)] args: Vec, #[serde(default, skip_serializing_if = "is_false")] tail: bool, diff --git a/crates/ailang-surface/src/parse.rs b/crates/ailang-surface/src/parse.rs index 14dda86..8c0fc36 100644 --- a/crates/ailang-surface/src/parse.rs +++ b/crates/ailang-surface/src/parse.rs @@ -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 { 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()?); diff --git a/design/contracts/data-model.md b/design/contracts/data-model.md index b1af7ae..8dab9b8 100644 --- a/design/contracts/data-model.md +++ b/design/contracts/data-model.md @@ -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": "", "value": Term, "body": Term } @@ -127,7 +130,10 @@ narrative — defaults, superclasses, diagnostics — lives in // `tail` triggers musttail (omitted when false). { "t": "do", "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": "", "ctor": "", "args": [Term...] } { "t": "match", "scrutinee": Term, "arms": [Arm...] } diff --git a/examples/nullary_app_smoke.ail b/examples/nullary_app_smoke.ail new file mode 100644 index 0000000..cc51e67 --- /dev/null +++ b/examples/nullary_app_smoke.ail @@ -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))))