diff --git a/crates/ail/tests/print_str_no_auto_newline_e2e.rs b/crates/ail/tests/print_str_no_auto_newline_e2e.rs new file mode 100644 index 0000000..c80677a --- /dev/null +++ b/crates/ail/tests/print_str_no_auto_newline_e2e.rs @@ -0,0 +1,87 @@ +//! RED tests for Gitea #29: `io/print_str` must print exactly the +//! bytes of its argument with no implicit trailing newline. +//! +//! Today the codegen at `crates/ailang-codegen/src/lib.rs:2734-2754` +//! lowers `(do io/print_str s)` to a C `@puts(ptr)` call. `puts` +//! writes the C string PLUS a trailing newline, so `io/print_str` is +//! de-facto `println_str`. That is undocumented in the design ledger +//! and was empirically caught in the cross-model-authoring naming-A/B +//! run on 2026-05-21: Qwen3-Coder repeatedly wrote +//! `(do io/print_str "text\n")` with explicit `\n`, expecting the +//! bytes to be printed literally — and got doubled newlines, failing +//! every t3_main_prints stdout match across all three cohorts. +//! +//! These tests will turn GREEN once the codegen swaps the `@puts` +//! call for `fputs(s, stdout)` (Option 1 from Gitea #29). Until +//! then they pin the symptom: two single-character prints emit +//! `"ab"`, not `"a\nb\n"`; one print of `"x\n"` emits `"x\n"`, not +//! `"x\n\n"`. +//! +//! NOTE: Unlike `show_print_e2e.rs` these assertions compare RAW +//! stdout — no `.trim()`. The whole point of #29 is the byte-level +//! property; trimming would mask both bugs. + +use std::path::PathBuf; +use std::process::Command; + +fn fixture_path(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../examples") + .join(name) +} + +fn build_and_run_raw(fixture: &str) -> String { + let src = fixture_path(fixture); + let out = std::env::temp_dir().join(format!("ail_{}.bin", fixture.replace('.', "_"))); + + let build = Command::new(env!("CARGO_BIN_EXE_ail")) + .args(["build", src.to_str().unwrap(), "-o", out.to_str().unwrap()]) + .output() + .expect("ail build"); + assert!( + build.status.success(), + "ail build failed for {fixture}:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&build.stdout), + String::from_utf8_lossy(&build.stderr), + ); + + let run = Command::new(&out).output().expect("run binary"); + assert!( + run.status.success(), + "binary exited non-zero:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr), + ); + // Raw stdout — no trimming. The bug is at the byte level. + String::from_utf8(run.stdout).expect("stdout is valid UTF-8") +} + +/// Property: two consecutive `(do io/print_str ...)` calls with +/// single-character no-newline arguments produce exactly the +/// concatenation of those characters on stdout — no separator, no +/// trailing newline. +/// +/// Fixture body: `(seq (do io/print_str "a") (do io/print_str "b"))`. +/// +/// Expected (post-fix): `"ab"`. +/// Today (pre-fix): `"a\nb\n"` — `@puts` appends a newline per call. +#[test] +fn io_print_str_does_not_append_auto_newline() { + let stdout = build_and_run_raw("print_str_no_auto_newline.ail"); + assert_eq!(stdout, "ab", "got: {stdout:?}"); +} + +/// Property: a single `(do io/print_str "x\n")` emits exactly one +/// newline — the one the author embedded. The codegen must not +/// double it. +/// +/// Fixture body: `(do io/print_str "x\n")`. +/// +/// Expected (post-fix): `"x\n"`. +/// Today (pre-fix): `"x\n\n"` — embedded `\n` plus auto-newline +/// from `@puts`. +#[test] +fn io_print_str_does_not_double_embedded_newline() { + let stdout = build_and_run_raw("print_str_embedded_newline_no_doubling.ail"); + assert_eq!(stdout, "x\n", "got: {stdout:?}"); +} diff --git a/examples/print_str_embedded_newline_no_doubling.ail b/examples/print_str_embedded_newline_no_doubling.ail new file mode 100644 index 0000000..66a29de --- /dev/null +++ b/examples/print_str_embedded_newline_no_doubling.ail @@ -0,0 +1,6 @@ +(module print_str_embedded_newline_no_doubling + (fn main + (doc "RED fixture for Gitea #29 (companion to print_str_no_auto_newline.ail): when the author embeds a single `\\n` in the argument, exactly one newline must reach stdout — not two. Today the codegen lowers io/print_str to `@puts(ptr)` which appends its own newline, so this program emits \"x\\n\\n\" (two newlines) instead of the author-intended \"x\\n\". Pins the no-doubling property as a separate axis from the no-auto-newline property: even authors who want a newline must not get a doubled one.") + (type (fn-type (params) (ret (con Unit)) (effects IO))) + (params) + (body (do io/print_str "x\n")))) diff --git a/examples/print_str_no_auto_newline.ail b/examples/print_str_no_auto_newline.ail new file mode 100644 index 0000000..95c8ced --- /dev/null +++ b/examples/print_str_no_auto_newline.ail @@ -0,0 +1,6 @@ +(module print_str_no_auto_newline + (fn main + (doc "RED fixture for Gitea #29: `io/print_str` must print exactly the bytes of its argument with NO auto-newline. Today the codegen lowers this op to a C `@puts(ptr)` call, which writes the string PLUS a trailing newline — so two consecutive prints of single-character strings emit \"a\\nb\\n\" instead of the expected \"ab\". This is the de-facto-println behaviour LLM authors (Qwen3-Coder, naming-A/B run 2026-05-21) trip over when they write explicit `\\n` and get doubled newlines. Property protected once the codegen swaps `@puts` for `fputs(s, stdout)`: io/print_str is a byte-faithful print, the author chooses whether to embed a newline.") + (type (fn-type (params) (ret (con Unit)) (effects IO))) + (params) + (body (seq (do io/print_str "a") (do io/print_str "b")))))