iter str-concat: heap-Str concatenation primitive in four-site lockstep

Closes fieldtest-form-a friction finding #4. `str_concat : (borrow Str,
borrow Str) -> Str` ships in the four-site-lockstep pattern established
by `str_clone` / `int_to_str` / `bool_to_str` (iter 24.1). The
LLM-natural Show-MyType body
`(app str_concat "label=" (app int_to_str x))` now parses, checks,
builds, and runs end-to-end.

Sites touched (lockstep):
- runtime/str.c — `ailang_str_concat(a, b)` slab-allocates and
  memcpys both source payloads into a new heap-Str.
- ailang-check/src/builtins.rs — `env.globals.insert("str_concat",
  Fn { 2x Str borrow, ret Str own, effects [] })` + `list()` row +
  `install_str_concat_signature` unit test.
- ailang-codegen/src/lib.rs — `declare ptr @ailang_str_concat(ptr,
  ptr)` extern + `lower_app` arm after str_clone + `is_builtin_callable`
  extension + IR-pin unit test `str_concat_emits_call_to_ailang_str_concat`.
- examples/show_user_adt_with_label.ail (new) + crates/ail/tests/
  str_concat_e2e.rs (new) — corpus fixture exercising the LLM-natural
  Show body shape + E2E pin asserting check + build + run produce
  `Item 42\n`.

Lockstep collision repaired: examples/bug_unbound_in_instance_method.ail
used `str_concat` as its UNBOUND name (because that was the literal
fieldtester repro). Renamed to `format_label` (LLM-author-realistic
helper name that will never become a builtin) and updated the pin test
`crates/ail/tests/unbound_in_instance_method_pin.rs` accordingly,
preserving the regression guard's intent (instance-method-body walked
through unbound-var check).

DESIGN.md amended: new §"Heap-Str primitives" subsection between the
milestone-24 Show-backer enumeration and the existing
`Primitive output goes through ...` paragraph, cataloguing all five
heap-Str primitives (`int_to_str`, `bool_to_str`, `float_to_str`,
`str_clone`, `str_concat`) with signatures, iter origins, and the
user-visible-vs-prelude-internal distinction. Show-backer block
unchanged.

IR snapshots regenerated (hello, list, max3, sum, ws_main) to absorb
the new `declare ptr @ailang_str_concat(ptr, ptr)` line in the
unconditional extern header — same upkeep pattern as hs.4 which
regenerated the same 5 snapshots for the same reason
(unconditional declares dead-stripped by clang -O2 when unused).

Tests: 559 + 3 = 562 green (E2E pin + builtin-signature test + IR-pin
test). Zero re-loops across all 7 tasks.
This commit is contained in:
2026-05-13 12:43:10 +02:00
parent 679572a92d
commit e7e67e1a40
16 changed files with 453 additions and 11 deletions
+69
View File
@@ -546,6 +546,7 @@ fn lower_workspace_inner(ws: &Workspace, alloc: AllocStrategy) -> Result<String>
// 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");
out.push_str("declare ptr @ailang_str_concat(ptr, 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
@@ -1975,6 +1976,26 @@ impl<'a> Emitter<'a> {
));
return Ok((dst, "ptr".to_string()));
}
if name == "str_concat" {
// Iter str-concat: lowers to the runtime C glue
// `ailang_str_concat(ptr, ptr) -> ptr` defined in
// `runtime/str.c`. Reads `len` from offset 0 of each
// source Str payload and allocates a fresh heap-Str
// slab sized for the combined bytes; works uniformly
// on static-Str and heap-Str inputs because the
// consumer ABI is identical. Common shape in Show
// bodies: `(app str_concat "label=" (app int_to_str x))`.
if args.len() != 2 {
return Err(CodegenError::Internal("str_concat arity".into()));
}
let (a, _) = self.lower_term(&args[0])?;
let (b, _) = self.lower_term(&args[1])?;
let dst = self.fresh_ssa();
self.body.push_str(&format!(
" {dst} = call ptr @ailang_str_concat(ptr {a}, ptr {b})\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`).
@@ -2190,6 +2211,7 @@ impl<'a> Emitter<'a> {
| "int_to_str"
| "bool_to_str"
| "str_clone"
| "str_concat"
) {
return true;
}
@@ -4291,4 +4313,51 @@ mod tests {
"expected lowering of str_clone to call @ailang_str_clone; ir was:\n{ir}"
);
}
/// Iter str-concat: a `Term::App` calling `str_concat` lowers to
/// `call ptr @ailang_str_concat(ptr %a, ptr %b)`. Pins the new
/// builtin's extern declaration AND the lower_app arm together
/// (mirror of the str_clone IR pin above).
#[test]
fn str_concat_emits_call_to_ailang_str_concat() {
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_concat".into() }),
args: vec![
Term::Lit { lit: Literal::Str { value: "x".into() } },
Term::Lit { lit: Literal::Str { value: "y".into() } },
],
tail: false,
}],
tail: false,
},
suppress: vec![],
doc: None,
})],
};
let ir = emit_ir(&m).unwrap();
assert!(
ir.contains("declare ptr @ailang_str_concat(ptr, ptr)"),
"expected extern declaration; ir was:\n{ir}"
);
assert!(
ir.contains("call ptr @ailang_str_concat(ptr "),
"expected lowering of str_concat to call @ailang_str_concat; ir was:\n{ir}"
);
}
}