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:
@@ -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
|
||||
}
|
||||
@@ -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).",
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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()?);
|
||||
|
||||
@@ -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...] }
|
||||
|
||||
@@ -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))))
|
||||
Reference in New Issue
Block a user