test(runtime): RED — whole-valued Float must render with .0 suffix

Adds `examples/print_float_whole_smoke.ail` (single `(app print
2.0)`) and `crates/ail/tests/print_float_whole_e2e.rs`. The test
builds and runs the fixture through the public `ail build` CLI
and asserts stdout is `"2.0\n"`.

Currently FAILS with `left: "2\n"`, `right: "2.0\n"` — runtime
`ailang_float_to_str` uses libc `snprintf("%g", x)` which strips
trailing zeros, so a whole-valued Float prints as Int-shaped
text. The surface printer at
`crates/ailang-surface/src/print.rs::write_float_lit` (lines
624-637) already enforces the `.0`-fallback property; the runtime
drifts. This RED pins the desired symmetry.

The GREEN side will mirror the surface fallback in
`runtime/str.c::ailang_float_to_str` and update the
`floats_e2e.rs` golden (`"4\n42\n-1.5\n"` → `"4.0\n42.0\n-1.5\n"`)
plus the doc comments at `crates/ail/tests/e2e.rs` and in the
runtime/example files that documented the previous `%g`-without-
fallback contract.

The alternative (document `%g`-behaviour in design/ ledger §Float
semantics as a deliberate human-friendly contract) was rejected:
the language's stated ethos is machine-readability + round-trip
identity (CLAUDE.md §"AILang — a language for LLM authors"); the
surface printer is the reference, runtime drift from it is a
defect not a feature.

refs #7
This commit is contained in:
2026-05-20 18:19:46 +02:00
parent a355fd861e
commit f19b0dd860
2 changed files with 80 additions and 0 deletions
+74
View File
@@ -0,0 +1,74 @@
//! Gitea #7 RED pin — whole-valued Float must render with a `.0` suffix.
//!
//! A Float literal whose value is whole (e.g. `2.0`), printed via the
//! polymorphic `print` builtin, monomorphises to the Show-Float path:
//! `Show Float.show` → `float_to_str` → runtime/str.c::
//! `ailang_float_to_str` → libc `snprintf(buf, sizeof(buf), "%g", x)`.
//!
//! The surface printer at
//! `crates/ailang-surface/src/print.rs::write_float_lit` (lines 624-637)
//! already enforces the property that a finite Float whose `to_string`
//! contains neither `.` nor `e/E` gets a `.0` suffix appended, so the
//! lex round-trip stays on the Float branch. The runtime path lacks
//! that fallback and therefore drifts: a Float-typed `2.0` prints as
//! `2`, which is Int-shaped output and corrupts the LLM author's
//! mental model of typing — a Float in the source can produce
//! Int-looking text in stdout.
//!
//! This test pins the desired property (post-fix): stdout for a single
//! `print 2.0` must be `"2.0\n"`. Pre-fix the test fails with `"2\n"`.
//!
//! Fix site: `runtime/str.c::ailang_float_to_str` — after the `%g`
//! snprintf, if the buffer is finite and contains neither `.` nor
//! `e/E`, append `.0` (mirroring the surface printer). Out of scope
//! for this RED.
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 whole-valued Float printed through `Show Float.show` keeps its
/// Float shape on stdout — i.e. the rendering contains a `.0` (or
/// equivalent `.` / `e/E` form) so a re-lex of the output would land
/// back on the Float branch, not on the Int branch. This is the
/// runtime mirror of `write_float_lit`'s surface-side `.0` fallback.
#[test]
fn print_whole_valued_float_renders_with_dot_zero_suffix() {
let out = build_and_run("print_float_whole_smoke.ail");
assert_eq!(
out, "2.0\n",
"whole-valued Float `2.0` must render with `.0` suffix \
(mirroring write_float_lit); got `{out}` — the runtime \
`ailang_float_to_str` is missing the `.0` fallback that \
the surface printer applies (see Gitea #7).",
);
}
+6
View File
@@ -0,0 +1,6 @@
(module print_float_whole_smoke
(fn main
(doc "RED pin for Gitea #7: a whole-valued Float (here, the literal 2.0) routed through polymorphic `print` (which monomorphises to Show Float.show -> float_to_str -> runtime/str.c::ailang_float_to_str -> libc snprintf %g) must render with a `.0` suffix, matching the surface printer's write_float_lit fallback (crates/ailang-surface/src/print.rs lines 624-637). The desired stdout for this fixture is the three bytes `2.0\\n`. Pre-fix, libc %g strips trailing zeros and renders `2\\n` — Int-shaped output for a Float value — which violates the surface-runtime round-trip-on-lex symmetry the language relies on.")
(type (fn-type (params) (ret (con Unit)) (effects IO)))
(params)
(body (app print 2.0))))