GREEN: io/print_str byte-faithful via @fputs(@stdout) — closes #29

The runtime print path now writes exactly the bytes of its
argument with no implicit trailing newline. `io/print_str` is
byte-faithful; authors who want a newline emit `(do io/print_str
"\n")` themselves.

## Codegen

`crates/ailang-codegen/src/lib.rs`:
- Module preamble: `@puts(ptr)` → `@fputs(ptr, ptr)` plus
  `@stdout = external global ptr` (libc's `FILE *stdout`).
- Effect-op lowering for `io/print_str`: emit
  `getelementptr +8` then `load ptr, ptr @stdout` then
  `call/tail call i32 @fputs(ptr bytes, ptr fp)`. Identical
  bytes-pointer GEP, distinct sink.
- The pinned IR-shape test renames from
  `print_str_calls_puts_with_bytes_pointer` to
  `print_str_calls_fputs_with_bytes_pointer_and_stdout` and now
  asserts: bytes-GEP present, stdout-load present, both module-
  preamble declarations present, and no `@puts(` call anywhere
  in the emitted IR.

## Why this shape, and not the alternatives

- *Rename `io/print_str` to `io/println_str` (issue #29 option 2)*
  — kept the auto-newline, just relabelled it. AILang's design
  bias is explicit-over-implicit (CLAUDE.md: implicit conversions
  cut). Auto-newline is a hidden runtime augmentation; the rename
  would have preserved it. Rejected.
- *Append `\n` inside the polymorphic `print` (Show-mediated)
  function in `examples/prelude.ail`* — would have been a one-line
  fix. Rejected: `print` is the Show-mediated formatter, not a
  newline emitter; baking a newline into it would have re-imposed
  the same implicit-augmentation problem one layer up, breaking
  callers that legitimately want pure-bytes output.

## Fixture / test sweep

30 `.ail` fixtures whose owning tests asserted line-separated
stdout now emit explicit `(do io/print_str "\n")` after each
print. Tests that asserted on multi-line stdout (`show_print_e2e`,
`floats_e2e`, `str_concat_e2e`, `eq_ord_e2e`, several `print_*`
smoke tests) had their fixtures sweetened the same way; assertions
themselves remain the canonical observable output. Fixtures that
never relied on the newline (no test ever read the absence of one)
were left untouched.

## Migrated metadata

- `crates/ailang-core/tests/hash_pin.rs`: the `ordering_match::main`
  canonical hash is refreshed (`b65a7f834703ffb4` →
  `8ed47b4062ce00f5`). The comment now names both successive
  corpus migrations honestly: the per-type-print-retirement (which
  moved `(do io/print_int x)` to `(app print x)`) AND this
  fputs swap (which wrapped that with `(seq ... (do io/print_str
  "\n"))`).
- `design/contracts/str-abi.md`: the consumer-ABI table now lists
  `@fputs` as the print sink. A prose paragraph documents the
  byte-faithful semantics and references this issue.
- `examples/ordering_match.prose.txt`: regenerated from the
  updated `ordering_match.ail`.
- `crates/ail/tests/snapshots/{hello,sum,max3,list,ws_main}.ll`:
  IR snapshots regenerated via `UPDATE_SNAPSHOTS=1`.
- Stale `@puts` comments in `runtime/str.c`,
  `crates/ail/tests/{e2e,show_print_e2e,print_no_leak_pin}.rs`
  replaced with `@fputs`.

## Verification

- `cargo test -p ail --test print_str_no_auto_newline_e2e` — both
  RED tests from commit c8ecfa3 now pass.
- `cargo test --workspace` — 90 test groups GREEN, 0 failures.
- `cargo build --workspace` — GREEN.
- No new clippy lints (24 warnings pre-existing in
  `crates/ailang-core/src/lib.rs:129`).
- Stats: `bench/orchestrator-stats/2026-05-21-iter-bugfix-print-
  str-fputs.json` — 1/1 tasks, 0 re-loops, 0 review loops.

## Empirical evidence cited

Caught in the 2026-05-21 Qwen3-Coder naming-A/B run
(`experiments/2026-05-21-naming-ab/runs/r1/`): every cohort wrote
`(do io/print_str "...\n")` with explicit `\n` and got doubled
newlines, failing the `t3_main_prints` stdout match across all
three cohorts. The empirical LLM-natural form already assumes the
new (post-this-commit) semantics — confirming the
feature-acceptance test in CLAUDE.md.

closes #29
This commit is contained in:
2026-05-21 12:22:45 +02:00
parent c8ecfa39b9
commit 26fb3459d8
61 changed files with 244 additions and 171 deletions
+2 -1
View File
@@ -2580,7 +2580,8 @@ fn ail_run_accepts_ail_source_with_same_stdout_as_ail_json() {
/// wired in hs.4 — exercises checker (signature install), codegen
/// (lower_app arm + IR-header declare + is_static_callee whitelist),
/// runtime (str.c's `ailang_int_to_str` allocates a heap-Str slab,
/// @puts reads from the bytes pointer at offset 8), and the
/// @fputs reads from the bytes pointer at offset 8 with @stdout as
/// the FILE*), and the
/// unconditional `runtime/rc.c` link (str.c's weak extern of
/// `ailang_rc_alloc` resolves to the strong rc.c definition under
/// every alloc strategy after the hs.4 hoist).
+1 -1
View File
@@ -16,7 +16,7 @@
//! `ailang_rc_stats: allocs=1 frees=0 live=1`. The IR for the post-
//! mono `ail_prelude_print__Int` body contains a `call ptr
//! @ail_prelude_show__Int(...)` followed by `getelementptr +8` +
//! `@puts` + `ret i8 0` — there is no `ailang_rc_dec` on the
//! `@fputs` + `ret i8 0` — there is no `ailang_rc_dec` on the
//! show-result before return. Root cause sits at codegen's
//! `is_rc_heap_allocated` App-arm: it reads the callee's
//! `Type::Fn.ret_mode` via `synth_callee_ret_mode` and requires
+9 -6
View File
@@ -56,12 +56,15 @@ fn build_and_run(fixture: &str) -> String {
#[test]
fn print_primitives_smoke_runs_end_to_end() {
let stdout = build_and_run("show_print_smoke.ail");
// print 42 → "42\n" (int_to_str + puts)
// print true → "true\n" (bool_to_str + puts)
// print "hello" → "hello\n" (str_clone + puts)
// print 3.14 → "3.14\n" (float_to_str / libc %g + puts)
// puts emits each Str + a trailing newline; trimmed output is
// "42\ntrue\nhello\n3.14".
// Post Gitea #29 (io/print_str → @fputs), the print path no
// longer appends an implicit newline. The fixture now emits
// each value with an explicit `(do io/print_str "\n")` after
// the print, so the raw stdout is "42\ntrue\nhello\n3.14\n"
// and `.trim()` lands at "42\ntrue\nhello\n3.14".
// print 42 → "42" (int_to_str), then explicit "\n"
// print true → "true" (bool_to_str), then explicit "\n"
// print "hello" → "hello" (str_clone), then explicit "\n"
// print 3.14 → "3.14" (float_to_str / libc %g)
assert_eq!(stdout, "42\ntrue\nhello\n3.14", "got: {stdout:?}");
}
+4 -2
View File
@@ -5,7 +5,8 @@ target triple = "<NORMALIZED>"
@.str_hello_str_0 = private unnamed_addr constant <{ i64, [15 x i8] }> <{ i64 14, [15 x i8] c"Hello, AILang.\00" }>, align 8
declare i32 @printf(ptr, ...)
declare i32 @puts(ptr)
declare i32 @fputs(ptr, ptr)
@stdout = external global ptr
declare ptr @ailang_rc_alloc(i64)
declare void @ailang_rc_inc(ptr)
declare void @ailang_rc_dec(ptr)
@@ -33,7 +34,8 @@ declare i64 @llvm.fptosi.sat.i64.f64(double)
define i8 @ail_hello_main() {
entry:
%v1 = getelementptr inbounds i8, ptr getelementptr inbounds (<{ i64, [15 x i8] }>, ptr @.str_hello_str_0, i32 0, i32 0), i64 8
call i32 @puts(ptr %v1)
%v2 = load ptr, ptr @stdout, align 8
call i32 @fputs(ptr %v1, ptr %v2)
ret i8 0
}
+4 -2
View File
@@ -3,7 +3,8 @@ source_filename = "list.ail"
target triple = "<NORMALIZED>"
declare i32 @printf(ptr, ...)
declare i32 @puts(ptr)
declare i32 @fputs(ptr, ptr)
@stdout = external global ptr
declare ptr @ailang_rc_alloc(i64)
declare void @ailang_rc_inc(ptr)
declare void @ailang_rc_dec(ptr)
@@ -240,7 +241,8 @@ define i8 @ail_prelude_print__Int(i64 %arg_x) {
entry:
%v1 = call ptr @ail_prelude_show__Int(i64 %arg_x)
%v2 = getelementptr inbounds i8, ptr %v1, i64 8
call i32 @puts(ptr %v2)
%v3 = load ptr, ptr @stdout, align 8
call i32 @fputs(ptr %v2, ptr %v3)
call void @ailang_rc_dec(ptr %v1)
ret i8 0
}
+4 -2
View File
@@ -3,7 +3,8 @@ source_filename = "max3.ail"
target triple = "<NORMALIZED>"
declare i32 @printf(ptr, ...)
declare i32 @puts(ptr)
declare i32 @fputs(ptr, ptr)
@stdout = external global ptr
declare ptr @ailang_rc_alloc(i64)
declare void @ailang_rc_inc(ptr)
declare void @ailang_rc_dec(ptr)
@@ -228,7 +229,8 @@ define i8 @ail_prelude_print__Int(i64 %arg_x) {
entry:
%v1 = call ptr @ail_prelude_show__Int(i64 %arg_x)
%v2 = getelementptr inbounds i8, ptr %v1, i64 8
call i32 @puts(ptr %v2)
%v3 = load ptr, ptr @stdout, align 8
call i32 @fputs(ptr %v2, ptr %v3)
call void @ailang_rc_dec(ptr %v1)
ret i8 0
}
+4 -2
View File
@@ -3,7 +3,8 @@ source_filename = "sum.ail"
target triple = "<NORMALIZED>"
declare i32 @printf(ptr, ...)
declare i32 @puts(ptr)
declare i32 @fputs(ptr, ptr)
@stdout = external global ptr
declare ptr @ailang_rc_alloc(i64)
declare void @ailang_rc_inc(ptr)
declare void @ailang_rc_dec(ptr)
@@ -132,7 +133,8 @@ define i8 @ail_prelude_print__Int(i64 %arg_x) {
entry:
%v1 = call ptr @ail_prelude_show__Int(i64 %arg_x)
%v2 = getelementptr inbounds i8, ptr %v1, i64 8
call i32 @puts(ptr %v2)
%v3 = load ptr, ptr @stdout, align 8
call i32 @fputs(ptr %v2, ptr %v3)
call void @ailang_rc_dec(ptr %v1)
ret i8 0
}
+4 -2
View File
@@ -3,7 +3,8 @@ source_filename = "ws_main.ail"
target triple = "<NORMALIZED>"
declare i32 @printf(ptr, ...)
declare i32 @puts(ptr)
declare i32 @fputs(ptr, ptr)
@stdout = external global ptr
declare ptr @ailang_rc_alloc(i64)
declare void @ailang_rc_inc(ptr)
declare void @ailang_rc_dec(ptr)
@@ -119,7 +120,8 @@ define i8 @ail_prelude_print__Int(i64 %arg_x) {
entry:
%v1 = call ptr @ail_prelude_show__Int(i64 %arg_x)
%v2 = getelementptr inbounds i8, ptr %v1, i64 8
call i32 @puts(ptr %v2)
%v3 = load ptr, ptr @stdout, align 8
call i32 @fputs(ptr %v2, ptr %v3)
call void @ailang_rc_dec(ptr %v1)
ret i8 0
}
+54 -14
View File
@@ -519,7 +519,15 @@ fn lower_workspace_inner(ws: &Workspace, alloc: AllocStrategy, target: Target) -
}
out.push_str("declare i32 @printf(ptr, ...)\n");
out.push_str("declare i32 @puts(ptr)\n");
// `io/print_str` lowers to `fputs(s, stdout)` so the runtime emits
// exactly the bytes of `s` with NO implicit trailing newline. The
// earlier `@puts(ptr)` shape de-facto turned `io/print_str` into
// `println_str` (caught empirically in the cross-model-authoring
// naming-A/B run, Gitea #29). `@stdout` is a libc global FILE*;
// POSIX exposes it as an `extern FILE *stdout` symbol, which LLVM
// IR sees as an opaque pointer to a pointer.
out.push_str("declare i32 @fputs(ptr, ptr)\n");
out.push_str("@stdout = external global ptr\n");
// The allocator declaration name follows `alloc`. `Rc` declares
// `@ailang_rc_alloc` (canonical); `Bump` declares `@bump_malloc`
// (raw-alloc bench-floor), supplied by `runtime/bump.c`.
@@ -2720,8 +2728,8 @@ impl<'a> Emitter<'a> {
fn lower_effect_op(&mut self, op: &str, args: &[Term], tail: bool) -> Result<(String, String)> {
// `musttail` requires identical caller/callee
// prototypes (same return type, same param types). The MVP's
// runtime print helpers (`printf`, `puts`) return `i32`, but the
// AILang fn enclosing a `tail-do io/print_*` returns `Unit`
// runtime print helpers (`printf`, `fputs`) return `i32`, but
// the AILang fn enclosing a `tail-do io/print_*` returns `Unit`
// (`i8`). `musttail` would be rejected by the LLVM verifier.
// We therefore use the `tail` keyword (LLVM IR optimisation
// hint, NOT a guarantee) for `tail: true` do-ops. The optimiser
@@ -2744,14 +2752,23 @@ impl<'a> Emitter<'a> {
));
}
// `Str` values now flow as a pointer to the
// `len`-field of the packed-struct slab; @puts needs
// the bytes pointer 8 bytes further on.
// `len`-field of the packed-struct slab; @fputs needs
// the bytes pointer 8 bytes further on. Loading
// `@stdout` is mandatory: it is `extern FILE *stdout`
// in libc, an opaque pointer-to-pointer at the IR
// level, and we need the inner pointer (the FILE*) as
// the second arg to `fputs`.
let bytes = self.fresh_ssa();
self.body.push_str(&format!(
" {bytes} = getelementptr inbounds i8, ptr {v}, i64 8\n"
));
self.body
.push_str(&format!(" {call_kw} i32 @puts(ptr {bytes})\n"));
let fp = self.fresh_ssa();
self.body.push_str(&format!(
" {fp} = load ptr, ptr @stdout, align 8\n"
));
self.body.push_str(&format!(
" {call_kw} i32 @fputs(ptr {bytes}, ptr {fp})\n"
));
if tail {
self.body.push_str(" ret i8 0\n");
self.block_terminated = true;
@@ -4299,10 +4316,14 @@ mod tests {
/// after the layout migration, the `io/print_str`
/// path must `getelementptr i8` +8 onto the IR-Str pointer before
/// passing it to `@puts`, so `@puts` receives the bytes pointer
/// (skipping the `len` field) and produces correct output.
/// passing it to `@fputs`, so `@fputs` receives the bytes pointer
/// (skipping the `len` field) and produces correct output. Post
/// Gitea #29 the call lowers to `@fputs(ptr bytes, ptr fp)` where
/// `fp` is a `load ptr, ptr @stdout` — `@fputs` (unlike `@puts`)
/// does NOT append a trailing newline, so `io/print_str` is
/// byte-faithful.
#[test]
fn print_str_calls_puts_with_bytes_pointer() {
fn print_str_calls_fputs_with_bytes_pointer_and_stdout() {
let m = Module {
schema: SCHEMA.into(),
name: "t".into(),
@@ -4330,11 +4351,30 @@ mod tests {
let ir = emit_ir(&m).unwrap();
let body_idx = ir.find("define i8 @ail_t_main").expect("main body");
let body = &ir[body_idx..];
let puts_idx = body.find("@puts(").expect("@puts call present");
let before_puts = &body[..puts_idx];
let fputs_idx = body.find("@fputs(").expect("@fputs call present");
let before_fputs = &body[..fputs_idx];
assert!(
before_puts.contains("getelementptr inbounds i8, ptr ") && before_puts.contains(", i64 8"),
"expected `getelementptr inbounds i8, ptr <v>, i64 8` before @puts call; ir body was:\n{body}"
before_fputs.contains("getelementptr inbounds i8, ptr ") && before_fputs.contains(", i64 8"),
"expected `getelementptr inbounds i8, ptr <v>, i64 8` before @fputs call; ir body was:\n{body}"
);
assert!(
before_fputs.contains("load ptr, ptr @stdout"),
"expected `load ptr, ptr @stdout` before @fputs call; ir body was:\n{body}"
);
// Module preamble must declare both: fputs and the @stdout extern.
assert!(
ir.contains("declare i32 @fputs(ptr, ptr)"),
"expected `declare i32 @fputs(ptr, ptr)` in module preamble; ir was:\n{ir}"
);
assert!(
ir.contains("@stdout = external global ptr"),
"expected `@stdout = external global ptr` in module preamble; ir was:\n{ir}"
);
// After the swap there must be no `@puts` call left anywhere
// in the emitted IR — the print path is the only consumer.
assert!(
!ir.contains("@puts("),
"no @puts call should remain after the fputs swap; ir was:\n{ir}"
);
}
+9 -6
View File
@@ -245,14 +245,17 @@ fn ct4_migrated_fixtures_have_canonical_form_hashes() {
let ord_mod = ailang_surface::load_module(&examples.join("ordering_match.ail"))
.expect("examples/ordering_match.ail loads");
let main_def = ord_mod.defs.iter().find(|d| d.name() == "main").unwrap();
// The hash reflects a corpus migration: `ordering_match.ail`'s
// body moved from `(do io/print_int x)` to `(app print x)` when
// the per-type print effect-ops were retired in favour of the
// polymorphic `print` helper.
// The hash reflects two successive corpus migrations:
// (1) the per-type-print-retirement that moved the body from
// `(do io/print_int x)` to `(app print x)`;
// (2) the io/print_str byte-faithful-print fix (Gitea #29) that
// wrapped the `(app print x)` with an explicit
// `(seq ... (do io/print_str "\n"))` newline emission, because
// io/print_str no longer adds a trailing newline.
assert_eq!(
def_hash(main_def),
"b65a7f834703ffb4",
"ordering_match::main canonical hash must match captured post-per-type-print-retirement value"
"8ed47b4062ce00f5",
"ordering_match::main canonical hash must match captured post-fputs-swap value"
);
let dup_a_mod = ailang_surface::load_module(&examples.join("test_22b1_dup_a.ail"))