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