iter 24.1: bool_to_str + str_clone runtime + codegen wiring

First iter of milestone 24 (Show + print rewire). Wires two new
heap-Str-producing primitives parallel to hs.4's int_to_str /
float_to_str:

- runtime/str.c gains ailang_bool_to_str(bool) → heap-Str "true" /
  "false" and ailang_str_clone(const char *) → memcpy'd heap-Str
  copy. Both use the existing str_alloc slab helper.
- builtins.rs + synth.rs install the two signatures lockstep with
  ret_mode: Own; str_clone carries param_modes: [Borrow].
- IR-header preamble gains two unconditional `declare ptr @...`
  lines; Emitter::lower_app gets two new arms; is_static_callee
  whitelist extends with the two names.
- Five IR snapshots regenerate for the two new declares.
- Pre-existing-drift fix: int_to_str row added to builtins.rs::list()
  (hs.4 installed env.globals entry but missed the list() row).

Substantive deviation flagged by orchestrator (DONE_WITH_CONCERNS):
builtin signatures registered in uniqueness.rs::infer_module and
linearity.rs::check_module_with_visible (8 LOC × 2 files), symmetric
to iter 23.4-prep's class-method registration in the same globals
maps. Without this fix str_clone's param_modes: [Borrow] is invisible
to the App-arg walker, src_heap walks as Position::Consume, the
scope-close ailang_rc_dec is gated off, and the
str_clone_cross_realisation_uniform_abi test's plan-literal
`frees == 3` assertion does not hold. The fix is the substantively
correct repair, not a design departure.

9 new tests: 2 builtins-install unit, 2 IR-shape unit pins, 5 E2E
(2 RC-stats, 2 stdout-smoke for both Bool branches, 1 cross-
realisation). 4 new .ail.json fixtures.

Full cargo test --workspace: 513 passed, 0 failed.
bench/compile_check.py: 24/24 stable. bench/cross_lang.py: 25/25
stable.
This commit is contained in:
2026-05-12 23:47:13 +02:00
parent 8bfa09adc7
commit f38bad8c2b
19 changed files with 726 additions and 0 deletions
+126
View File
@@ -536,6 +536,16 @@ fn lower_workspace_inner(ws: &Workspace, alloc: AllocStrategy) -> Result<String>
// clang -O2 dead-strips the declarations when no caller exists.
out.push_str("declare ptr @ailang_int_to_str(i64)\n");
out.push_str("declare ptr @ailang_float_to_str(double)\n");
// Iter 24.1: heap-Str primitive externs for the `show` instances
// — `ailang_bool_to_str` (Show Bool) and `ailang_str_clone`
// (Show Str). Same dual-realisation ABI as int_to_str /
// float_to_str: rc_header at offset -8 on the heap-Str output;
// consumer ABI shared with static-Str (len at offset 0, bytes
// at offset 8). Declared unconditionally on the same rationale
// — runtime/str.c is unconditionally linked and clang -O2 dead-
// strips when no caller exists.
out.push_str("declare ptr @ailang_bool_to_str(i1)\n");
out.push_str("declare ptr @ailang_str_clone(ptr)\n");
// Floats iter 4.4: saturating fp-to-int intrinsic for
// float_to_int_truncate. NaN → 0, +Inf → i64::MAX, -Inf →
// i64::MIN, finite-out-of-range saturates, finite-in-range
@@ -1931,6 +1941,40 @@ impl<'a> Emitter<'a> {
));
return Ok((dst, "ptr".to_string()));
}
if name == "bool_to_str" {
// Iter 24.1: lowers to the runtime C glue
// `ailang_bool_to_str(i1) -> ptr` defined in
// `runtime/str.c`. Returned pointer is a heap-Str
// (rc_header at offset -8; consumer ABI shared with
// static-Str). Used by `show__Bool` in milestone 24.
if args.len() != 1 {
return Err(CodegenError::Internal("bool_to_str arity".into()));
}
let (a, _) = self.lower_term(&args[0])?;
let dst = self.fresh_ssa();
self.body.push_str(&format!(
" {dst} = call ptr @ailang_bool_to_str(i1 {a})\n"
));
return Ok((dst, "ptr".to_string()));
}
if name == "str_clone" {
// Iter 24.1: lowers to the runtime C glue
// `ailang_str_clone(ptr) -> ptr` defined in
// `runtime/str.c`. Reads `len` from offset 0 of the
// source Str payload and allocates a fresh heap-Str
// slab; works uniformly on static-Str and heap-Str
// inputs because the consumer ABI is identical. Used by
// `show__Str` in milestone 24.
if args.len() != 1 {
return Err(CodegenError::Internal("str_clone arity".into()));
}
let (a, _) = self.lower_term(&args[0])?;
let dst = self.fresh_ssa();
self.body.push_str(&format!(
" {dst} = call ptr @ailang_str_clone(ptr {a})\n"
));
return Ok((dst, "ptr".to_string()));
}
// Cross-module call: exactly one dot in the name → resolve via import map.
// Logic identical to the typechecker (see `synth` for `Term::Var`).
@@ -2131,6 +2175,8 @@ impl<'a> Emitter<'a> {
| "is_nan"
| "float_to_str"
| "int_to_str"
| "bool_to_str"
| "str_clone"
) {
return true;
}
@@ -4202,4 +4248,84 @@ mod tests {
"expected lowering of float_to_str to call @ailang_float_to_str; ir was:\n{ir}"
);
}
/// Iter 24.1: a `Term::App` calling `bool_to_str` lowers to
/// `call ptr @ailang_bool_to_str(i1 %a)`. Pins the new builtin's
/// lowering shape against the runtime C glue introduced in this
/// iter (mirror of hs.4's `int_to_str_lowers_to_ailang_int_to_str_call`).
#[test]
fn bool_to_str_emits_call_to_ailang_bool_to_str() {
let m = Module {
schema: SCHEMA.into(),
name: "t".into(),
imports: vec![],
defs: vec![Def::Fn(FnDef {
name: "main".into(),
ty: Type::Fn {
params: vec![],
ret: Box::new(Type::unit()),
effects: vec!["IO".into()],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
},
params: vec![],
body: Term::Do {
op: "io/print_str".into(),
args: vec![Term::App {
callee: Box::new(Term::Var { name: "bool_to_str".into() }),
args: vec![Term::Lit { lit: Literal::Bool { value: true } }],
tail: false,
}],
tail: false,
},
suppress: vec![],
doc: None,
})],
};
let ir = emit_ir(&m).unwrap();
assert!(
ir.contains("call ptr @ailang_bool_to_str(i1 "),
"expected lowering of bool_to_str to call @ailang_bool_to_str; ir was:\n{ir}"
);
}
/// Iter 24.1: a `Term::App` calling `str_clone` lowers to
/// `call ptr @ailang_str_clone(ptr %a)`. Pins the new builtin's
/// lowering shape against the runtime C glue introduced in this
/// iter (mirror of hs.4's `int_to_str_lowers_to_ailang_int_to_str_call`).
#[test]
fn str_clone_emits_call_to_ailang_str_clone() {
let m = Module {
schema: SCHEMA.into(),
name: "t".into(),
imports: vec![],
defs: vec![Def::Fn(FnDef {
name: "main".into(),
ty: Type::Fn {
params: vec![],
ret: Box::new(Type::unit()),
effects: vec!["IO".into()],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
},
params: vec![],
body: Term::Do {
op: "io/print_str".into(),
args: vec![Term::App {
callee: Box::new(Term::Var { name: "str_clone".into() }),
args: vec![Term::Lit { lit: Literal::Str { value: "hi".into() } }],
tail: false,
}],
tail: false,
},
suppress: vec![],
doc: None,
})],
};
let ir = emit_ir(&m).unwrap();
assert!(
ir.contains("call ptr @ailang_str_clone(ptr "),
"expected lowering of str_clone to call @ailang_str_clone; ir was:\n{ir}"
);
}
}
+14
View File
@@ -178,6 +178,20 @@ pub(crate) fn builtin_ail_type(name: &str) -> Option<Type> {
param_modes: vec![],
ret_mode: ParamMode::Own,
},
"bool_to_str" => Type::Fn {
params: vec![Type::bool_()],
ret: Box::new(Type::str_()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Own,
},
"str_clone" => Type::Fn {
params: vec![Type::str_()],
ret: Box::new(Type::str_()),
effects: vec![],
param_modes: vec![ParamMode::Borrow],
ret_mode: ParamMode::Own,
},
"is_nan" => Type::Fn {
params: vec![Type::float()],
ret: Box::new(Type::bool_()),