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:
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"iter_id": "str-concat",
|
||||||
|
"date": "2026-05-13",
|
||||||
|
"mode": "standard",
|
||||||
|
"outcome": "DONE",
|
||||||
|
"tasks_total": 7,
|
||||||
|
"tasks_completed": 7,
|
||||||
|
"reloops_per_task": {
|
||||||
|
"1": 0,
|
||||||
|
"2": 0,
|
||||||
|
"3": 0,
|
||||||
|
"4": 0,
|
||||||
|
"5": 0,
|
||||||
|
"6": 0,
|
||||||
|
"7": 0
|
||||||
|
},
|
||||||
|
"review_loops_spec": 0,
|
||||||
|
"review_loops_quality": 0,
|
||||||
|
"blocked_reason": null
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ declare ptr @ailang_int_to_str(i64)
|
|||||||
declare ptr @ailang_float_to_str(double)
|
declare ptr @ailang_float_to_str(double)
|
||||||
declare ptr @ailang_bool_to_str(i1)
|
declare ptr @ailang_bool_to_str(i1)
|
||||||
declare ptr @ailang_str_clone(ptr)
|
declare ptr @ailang_str_clone(ptr)
|
||||||
|
declare ptr @ailang_str_concat(ptr, ptr)
|
||||||
declare i64 @llvm.fptosi.sat.i64.f64(double)
|
declare i64 @llvm.fptosi.sat.i64.f64(double)
|
||||||
|
|
||||||
@ail_hello_main_clos = private unnamed_addr constant { ptr, ptr } { ptr @ail_hello_main_adapter, ptr null }
|
@ail_hello_main_clos = private unnamed_addr constant { ptr, ptr } { ptr @ail_hello_main_adapter, ptr null }
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ declare ptr @ailang_int_to_str(i64)
|
|||||||
declare ptr @ailang_float_to_str(double)
|
declare ptr @ailang_float_to_str(double)
|
||||||
declare ptr @ailang_bool_to_str(i1)
|
declare ptr @ailang_bool_to_str(i1)
|
||||||
declare ptr @ailang_str_clone(ptr)
|
declare ptr @ailang_str_clone(ptr)
|
||||||
|
declare ptr @ailang_str_concat(ptr, ptr)
|
||||||
declare i64 @llvm.fptosi.sat.i64.f64(double)
|
declare i64 @llvm.fptosi.sat.i64.f64(double)
|
||||||
|
|
||||||
@ail_list_sum_list_clos = private unnamed_addr constant { ptr, ptr } { ptr @ail_list_sum_list_adapter, ptr null }
|
@ail_list_sum_list_clos = private unnamed_addr constant { ptr, ptr } { ptr @ail_list_sum_list_adapter, ptr null }
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ declare ptr @ailang_int_to_str(i64)
|
|||||||
declare ptr @ailang_float_to_str(double)
|
declare ptr @ailang_float_to_str(double)
|
||||||
declare ptr @ailang_bool_to_str(i1)
|
declare ptr @ailang_bool_to_str(i1)
|
||||||
declare ptr @ailang_str_clone(ptr)
|
declare ptr @ailang_str_clone(ptr)
|
||||||
|
declare ptr @ailang_str_concat(ptr, ptr)
|
||||||
declare i64 @llvm.fptosi.sat.i64.f64(double)
|
declare i64 @llvm.fptosi.sat.i64.f64(double)
|
||||||
|
|
||||||
@ail_max3_max_clos = private unnamed_addr constant { ptr, ptr } { ptr @ail_max3_max_adapter, ptr null }
|
@ail_max3_max_clos = private unnamed_addr constant { ptr, ptr } { ptr @ail_max3_max_adapter, ptr null }
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ declare ptr @ailang_int_to_str(i64)
|
|||||||
declare ptr @ailang_float_to_str(double)
|
declare ptr @ailang_float_to_str(double)
|
||||||
declare ptr @ailang_bool_to_str(i1)
|
declare ptr @ailang_bool_to_str(i1)
|
||||||
declare ptr @ailang_str_clone(ptr)
|
declare ptr @ailang_str_clone(ptr)
|
||||||
|
declare ptr @ailang_str_concat(ptr, ptr)
|
||||||
declare i64 @llvm.fptosi.sat.i64.f64(double)
|
declare i64 @llvm.fptosi.sat.i64.f64(double)
|
||||||
|
|
||||||
@ail_sum_sum_clos = private unnamed_addr constant { ptr, ptr } { ptr @ail_sum_sum_adapter, ptr null }
|
@ail_sum_sum_clos = private unnamed_addr constant { ptr, ptr } { ptr @ail_sum_sum_adapter, ptr null }
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ declare ptr @ailang_int_to_str(i64)
|
|||||||
declare ptr @ailang_float_to_str(double)
|
declare ptr @ailang_float_to_str(double)
|
||||||
declare ptr @ailang_bool_to_str(i1)
|
declare ptr @ailang_bool_to_str(i1)
|
||||||
declare ptr @ailang_str_clone(ptr)
|
declare ptr @ailang_str_clone(ptr)
|
||||||
|
declare ptr @ailang_str_concat(ptr, ptr)
|
||||||
declare i64 @llvm.fptosi.sat.i64.f64(double)
|
declare i64 @llvm.fptosi.sat.i64.f64(double)
|
||||||
|
|
||||||
@ail_ws_lib_add_clos = private unnamed_addr constant { ptr, ptr } { ptr @ail_ws_lib_add_adapter, ptr null }
|
@ail_ws_lib_add_clos = private unnamed_addr constant { ptr, ptr } { ptr @ail_ws_lib_add_adapter, ptr null }
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
//! E2E pin for the `str_concat` heap-Str primitive shipped in iter
|
||||||
|
//! `str-concat`. Asserts that `ail check` + `ail build` + run on
|
||||||
|
//! `examples/show_user_adt_with_label.ail` produce stdout
|
||||||
|
//! `Item 42\n` — i.e. that the Show body's
|
||||||
|
//! `(app str_concat "Item " (app int_to_str n))` evaluates correctly
|
||||||
|
//! and `print` emits the concatenated heap-Str.
|
||||||
|
//!
|
||||||
|
//! Without `str_concat` registered as a builtin (pre-iter state), the
|
||||||
|
//! fixture fails `ail check` with `[unbound-var]: str_concat`. After
|
||||||
|
//! the iter ships, both check and build succeed and the binary
|
||||||
|
//! produces the expected stdout.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
fn ail_bin() -> &'static str {
|
||||||
|
env!("CARGO_BIN_EXE_ail")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn str_concat_e2e_show_user_adt_with_label() {
|
||||||
|
let manifest_dir = env!("CARGO_MANIFEST_DIR");
|
||||||
|
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
|
||||||
|
let src = workspace
|
||||||
|
.join("examples")
|
||||||
|
.join("show_user_adt_with_label.ail");
|
||||||
|
assert!(src.exists(), "fixture missing: {}", src.display());
|
||||||
|
|
||||||
|
let check = Command::new(ail_bin())
|
||||||
|
.args(["check", src.to_str().unwrap()])
|
||||||
|
.output()
|
||||||
|
.expect("ail check failed to spawn");
|
||||||
|
assert_eq!(
|
||||||
|
check.status.code(),
|
||||||
|
Some(0),
|
||||||
|
"ail check must succeed; got stdout={} stderr={}",
|
||||||
|
String::from_utf8_lossy(&check.stdout),
|
||||||
|
String::from_utf8_lossy(&check.stderr)
|
||||||
|
);
|
||||||
|
|
||||||
|
let tmp = tempfile::tempdir().expect("tempdir");
|
||||||
|
let bin_path = tmp.path().join("a.out");
|
||||||
|
let build = Command::new(ail_bin())
|
||||||
|
.args([
|
||||||
|
"build",
|
||||||
|
src.to_str().unwrap(),
|
||||||
|
"-o",
|
||||||
|
bin_path.to_str().unwrap(),
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.expect("ail build failed to spawn");
|
||||||
|
assert_eq!(
|
||||||
|
build.status.code(),
|
||||||
|
Some(0),
|
||||||
|
"ail build must succeed; got stdout={} stderr={}",
|
||||||
|
String::from_utf8_lossy(&build.stdout),
|
||||||
|
String::from_utf8_lossy(&build.stderr)
|
||||||
|
);
|
||||||
|
|
||||||
|
let run = Command::new(&bin_path)
|
||||||
|
.output()
|
||||||
|
.expect("produced binary failed to spawn");
|
||||||
|
assert_eq!(run.status.code(), Some(0), "binary must exit 0");
|
||||||
|
let stdout = String::from_utf8_lossy(&run.stdout);
|
||||||
|
assert_eq!(
|
||||||
|
stdout, "Item 42\n",
|
||||||
|
"expected `Item 42\\n` from str_concat + int_to_str + print; got {stdout:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,11 +14,11 @@
|
|||||||
//! Pre-fix observed behaviour (the bug):
|
//! Pre-fix observed behaviour (the bug):
|
||||||
//! - `ail check` exits 0 with `ok (23 symbols across 2 modules)`.
|
//! - `ail check` exits 0 with `ok (23 symbols across 2 modules)`.
|
||||||
//! - `ail build` exits 1 with
|
//! - `ail build` exits 1 with
|
||||||
//! `Error: monomorphise_workspace: unknown identifier: \`str_concat\``.
|
//! `Error: monomorphise_workspace: unknown identifier: \`format_label\``.
|
||||||
//!
|
//!
|
||||||
//! Post-fix expected behaviour:
|
//! Post-fix expected behaviour:
|
||||||
//! - `ail check` exits 1 with an `[unbound-var]` error naming
|
//! - `ail check` exits 1 with an `[unbound-var]` error naming
|
||||||
//! `str_concat`. The mono pass never runs because check fails first.
|
//! `format_label`. The mono pass never runs because check fails first.
|
||||||
//!
|
//!
|
||||||
//! Root cause (from debugger Phase 1-2):
|
//! Root cause (from debugger Phase 1-2):
|
||||||
//! `crates/ailang-check/src/lib.rs::check_def` early-returns `Ok(())`
|
//! `crates/ailang-check/src/lib.rs::check_def` early-returns `Ok(())`
|
||||||
@@ -30,9 +30,9 @@
|
|||||||
//! method-body identifier graph.
|
//! method-body identifier graph.
|
||||||
//!
|
//!
|
||||||
//! Fixture: `examples/bug_unbound_in_instance_method.ail`. The fixture
|
//! Fixture: `examples/bug_unbound_in_instance_method.ail`. The fixture
|
||||||
//! uses `str_concat` (NOT a builtin) inside an instance-method lambda;
|
//! uses `format_label` (NOT a builtin) inside an instance-method lambda;
|
||||||
//! `int_to_str` IS a builtin and is correctly resolved. The fixture
|
//! `int_to_str` IS a builtin and is correctly resolved. The fixture
|
||||||
//! parses cleanly — the only error is the unbound `str_concat` in the
|
//! parses cleanly — the only error is the unbound `format_label` in the
|
||||||
//! method body.
|
//! method body.
|
||||||
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
@@ -43,13 +43,13 @@ fn ail_bin() -> &'static str {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// RED today: `ail check` on `bug_unbound_in_instance_method.ail`
|
/// RED today: `ail check` on `bug_unbound_in_instance_method.ail`
|
||||||
/// must exit non-zero and emit `[unbound-var]` naming `str_concat`,
|
/// must exit non-zero and emit `[unbound-var]` naming `format_label`,
|
||||||
/// matching the diagnostic shape produced at fn-body level.
|
/// matching the diagnostic shape produced at fn-body level.
|
||||||
///
|
///
|
||||||
/// Pre-fix, this test fails because `ail check` exits 0 and prints
|
/// Pre-fix, this test fails because `ail check` exits 0 and prints
|
||||||
/// `ok (23 symbols across 2 modules)`.
|
/// `ok (23 symbols across 2 modules)`.
|
||||||
#[test]
|
#[test]
|
||||||
fn check_fires_unbound_var_for_str_concat_in_instance_method_body() {
|
fn check_fires_unbound_var_for_format_label_in_instance_method_body() {
|
||||||
let manifest_dir = env!("CARGO_MANIFEST_DIR");
|
let manifest_dir = env!("CARGO_MANIFEST_DIR");
|
||||||
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
|
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
|
||||||
let src = workspace
|
let src = workspace
|
||||||
@@ -81,8 +81,8 @@ fn check_fires_unbound_var_for_str_concat_in_instance_method_body() {
|
|||||||
got: {combined}"
|
got: {combined}"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
combined.contains("str_concat"),
|
combined.contains("format_label"),
|
||||||
"diagnostic must name the unbound identifier `str_concat`; \
|
"diagnostic must name the unbound identifier `format_label`; \
|
||||||
got: {combined}"
|
got: {combined}"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
@@ -128,9 +128,9 @@ fn check_json_unbound_var_in_instance_method_body() {
|
|||||||
arr.iter().any(|d| {
|
arr.iter().any(|d| {
|
||||||
d.get("severity").and_then(|v| v.as_str()) == Some("error")
|
d.get("severity").and_then(|v| v.as_str()) == Some("error")
|
||||||
&& d.get("code").and_then(|v| v.as_str()) == Some("unbound-var")
|
&& d.get("code").and_then(|v| v.as_str()) == Some("unbound-var")
|
||||||
&& d.to_string().contains("str_concat")
|
&& d.to_string().contains("format_label")
|
||||||
}),
|
}),
|
||||||
"expected an error diagnostic with code `unbound-var` naming \
|
"expected an error diagnostic with code `unbound-var` naming \
|
||||||
`str_concat`; got: {stdout}"
|
`format_label`; got: {stdout}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -230,6 +230,19 @@ pub fn install(env: &mut crate::Env) {
|
|||||||
ret_mode: ailang_core::ast::ParamMode::Own,
|
ret_mode: ailang_core::ast::ParamMode::Own,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
env.globals.insert(
|
||||||
|
"str_concat".into(),
|
||||||
|
Type::Fn {
|
||||||
|
params: vec![Type::str_(), Type::str_()],
|
||||||
|
ret: Box::new(Type::str_()),
|
||||||
|
effects: vec![],
|
||||||
|
param_modes: vec![
|
||||||
|
ailang_core::ast::ParamMode::Borrow,
|
||||||
|
ailang_core::ast::ParamMode::Borrow,
|
||||||
|
],
|
||||||
|
ret_mode: ailang_core::ast::ParamMode::Own,
|
||||||
|
},
|
||||||
|
);
|
||||||
env.globals.insert(
|
env.globals.insert(
|
||||||
"is_nan".into(),
|
"is_nan".into(),
|
||||||
Type::Fn {
|
Type::Fn {
|
||||||
@@ -328,6 +341,7 @@ pub fn list() -> Vec<(&'static str, &'static str)> {
|
|||||||
("int_to_str", "(Int) -> Str"),
|
("int_to_str", "(Int) -> Str"),
|
||||||
("bool_to_str", "(Bool) -> Str"),
|
("bool_to_str", "(Bool) -> Str"),
|
||||||
("str_clone", "(Str) -> Str"),
|
("str_clone", "(Str) -> Str"),
|
||||||
|
("str_concat", "(Str, Str) -> Str"),
|
||||||
("is_nan", "(Float) -> Bool"),
|
("is_nan", "(Float) -> Bool"),
|
||||||
("nan", "Float"),
|
("nan", "Float"),
|
||||||
("inf", "Float"),
|
("inf", "Float"),
|
||||||
@@ -513,6 +527,43 @@ mod tests {
|
|||||||
assert_eq!(ty, Type::str_(), "(str_clone \"hi\") must type as Str");
|
assert_eq!(ty, Type::str_(), "(str_clone \"hi\") must type as Str");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn install_str_concat_signature() {
|
||||||
|
// Iter str-concat: `str_concat : (Str borrow, Str borrow) -> Str
|
||||||
|
// own`. Codegen lowers via the runtime C glue `ailang_str_concat`
|
||||||
|
// from `runtime/str.c`.
|
||||||
|
let mut env = Env::default();
|
||||||
|
install(&mut env);
|
||||||
|
let ty = env
|
||||||
|
.globals
|
||||||
|
.get("str_concat")
|
||||||
|
.expect("str_concat must be installed");
|
||||||
|
match ty {
|
||||||
|
Type::Fn {
|
||||||
|
params,
|
||||||
|
ret,
|
||||||
|
effects,
|
||||||
|
param_modes,
|
||||||
|
ret_mode,
|
||||||
|
} => {
|
||||||
|
assert_eq!(params.len(), 2);
|
||||||
|
assert!(matches!(params[0], Type::Con { ref name, .. } if name == "Str"));
|
||||||
|
assert!(matches!(params[1], Type::Con { ref name, .. } if name == "Str"));
|
||||||
|
assert!(matches!(**ret, Type::Con { ref name, .. } if name == "Str"));
|
||||||
|
assert!(effects.is_empty(), "str_concat must be effect-free");
|
||||||
|
assert_eq!(
|
||||||
|
param_modes,
|
||||||
|
&vec![
|
||||||
|
ailang_core::ast::ParamMode::Borrow,
|
||||||
|
ailang_core::ast::ParamMode::Borrow,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(*ret_mode, ailang_core::ast::ParamMode::Own);
|
||||||
|
}
|
||||||
|
other => panic!("expected Type::Fn; got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Iter 22-floats.3: `is_nan : (Float) -> Bool`. Codegen lowers to
|
/// Iter 22-floats.3: `is_nan : (Float) -> Bool`. Codegen lowers to
|
||||||
/// `fcmp uno double %x, %x` in iter 4 — typecheck only validates
|
/// `fcmp uno double %x, %x` in iter 4 — typecheck only validates
|
||||||
/// the signature here.
|
/// the signature here.
|
||||||
|
|||||||
@@ -546,6 +546,7 @@ fn lower_workspace_inner(ws: &Workspace, alloc: AllocStrategy) -> Result<String>
|
|||||||
// strips when no caller exists.
|
// strips when no caller exists.
|
||||||
out.push_str("declare ptr @ailang_bool_to_str(i1)\n");
|
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_clone(ptr)\n");
|
||||||
|
out.push_str("declare ptr @ailang_str_concat(ptr, ptr)\n");
|
||||||
// Floats iter 4.4: saturating fp-to-int intrinsic for
|
// Floats iter 4.4: saturating fp-to-int intrinsic for
|
||||||
// float_to_int_truncate. NaN → 0, +Inf → i64::MAX, -Inf →
|
// float_to_int_truncate. NaN → 0, +Inf → i64::MAX, -Inf →
|
||||||
// i64::MIN, finite-out-of-range saturates, finite-in-range
|
// i64::MIN, finite-out-of-range saturates, finite-in-range
|
||||||
@@ -1975,6 +1976,26 @@ impl<'a> Emitter<'a> {
|
|||||||
));
|
));
|
||||||
return Ok((dst, "ptr".to_string()));
|
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.
|
// Cross-module call: exactly one dot in the name → resolve via import map.
|
||||||
// Logic identical to the typechecker (see `synth` for `Term::Var`).
|
// Logic identical to the typechecker (see `synth` for `Term::Var`).
|
||||||
@@ -2190,6 +2211,7 @@ impl<'a> Emitter<'a> {
|
|||||||
| "int_to_str"
|
| "int_to_str"
|
||||||
| "bool_to_str"
|
| "bool_to_str"
|
||||||
| "str_clone"
|
| "str_clone"
|
||||||
|
| "str_concat"
|
||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -4291,4 +4313,51 @@ mod tests {
|
|||||||
"expected lowering of str_clone to call @ailang_str_clone; ir was:\n{ir}"
|
"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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1991,6 +1991,36 @@ Routing through `print` replaces the ad-hoc `io/print_int|bool|float`
|
|||||||
idiom for new code; retiring the per-type effect-ops is queued as a
|
idiom for new code; retiring the per-type effect-ops is queued as a
|
||||||
P2 follow-up.
|
P2 follow-up.
|
||||||
|
|
||||||
|
### Heap-Str primitives
|
||||||
|
|
||||||
|
The runtime ships a small family of operations that produce or
|
||||||
|
transform heap-allocated `Str` values uniformly across static-Str
|
||||||
|
and heap-Str inputs (the consumer ABI is identical between
|
||||||
|
realisations — see §"Str ABI"). All take their input(s) by `borrow`
|
||||||
|
and return an owned `Str`. Each is registered as a builtin in
|
||||||
|
`crates/ailang-check/src/builtins.rs`, lowered inline in
|
||||||
|
`crates/ailang-codegen/src/lib.rs::lower_app` to a `call ptr @ailang_<name>`,
|
||||||
|
and backed by a `runtime/str.c` C helper.
|
||||||
|
|
||||||
|
- `int_to_str : (borrow Int) -> Str` (iter 24.1) — decimal rendering
|
||||||
|
of an `Int`. Backs `Show Int` in the prelude.
|
||||||
|
- `bool_to_str : (borrow Bool) -> Str` (iter 24.1) — `"true"`/`"false"`.
|
||||||
|
Backs `Show Bool` in the prelude.
|
||||||
|
- `float_to_str : (borrow Float) -> Str` (iter 24.1, type-installed;
|
||||||
|
codegen ships in a future iter per roadmap). Will back `Show Float`.
|
||||||
|
- `str_clone : (borrow Str) -> Str` (iter 24.1) — allocates a fresh
|
||||||
|
heap-Str copy of the input's bytes. Backs `Show Str` in the prelude.
|
||||||
|
- `str_concat : (borrow Str, borrow Str) -> Str` (iter str-concat,
|
||||||
|
2026-05-13) — combines two `Str` values into a single owned `Str`.
|
||||||
|
General-purpose; commonly used in Show bodies for labelled output
|
||||||
|
(`(app str_concat "label=" (app int_to_str x))`).
|
||||||
|
|
||||||
|
The four Show-backers above are not directly observable to the
|
||||||
|
LLM-author writing a `Show <T>` instance — the prelude's instance
|
||||||
|
bodies dispatch into them. `str_concat` IS directly observable
|
||||||
|
because the LLM-author calls it explicitly when authoring an
|
||||||
|
instance body that wants to combine fragments.
|
||||||
|
|
||||||
Primitive output goes through `io/print_int` / `io/print_bool` /
|
Primitive output goes through `io/print_int` / `io/print_bool` /
|
||||||
`io/print_str` directly.
|
`io/print_str` directly.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
# iter str-concat — heap-Str concatenation primitive in four-site lockstep
|
||||||
|
|
||||||
|
**Date:** 2026-05-13
|
||||||
|
**Started from:** 679572a92d594d94b915d09fd60e7ef7f7bb83bd
|
||||||
|
**Status:** DONE
|
||||||
|
**Tasks completed:** 7 of 7
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
`str_concat : (borrow Str, borrow Str) -> Str` shipped as a heap-Str
|
||||||
|
primitive in the four-site-lockstep pattern established by `str_clone`
|
||||||
|
(iter 24.1). Closes fieldtest-form-a friction finding #4. The
|
||||||
|
LLM-natural Show-body shape
|
||||||
|
`(app str_concat "label=" (app int_to_str x))` now parses, checks,
|
||||||
|
builds, and runs end-to-end via the new
|
||||||
|
`examples/show_user_adt_with_label.ail` fixture (`Item 42\n`
|
||||||
|
through `print . MkItem 42`).
|
||||||
|
|
||||||
|
Net delta: +3 tests (559 baseline → 562 green): the E2E pin
|
||||||
|
(`str_concat_e2e_show_user_adt_with_label`), the builtin-signature
|
||||||
|
unit test (`install_str_concat_signature` asserting the arity-2
|
||||||
|
Borrow-Borrow / Own-return shape directly on `env.globals`), and the
|
||||||
|
codegen IR-pin (`str_concat_emits_call_to_ailang_str_concat`
|
||||||
|
asserting both the extern declaration AND the call instruction in
|
||||||
|
emitted IR). Five `crates/ail/tests/snapshots/*.ll` files were
|
||||||
|
regenerated to absorb the new unconditional
|
||||||
|
`declare ptr @ailang_str_concat(ptr, ptr)` line in the IR header
|
||||||
|
preamble — same upkeep pattern as hs.4 (which regenerated the same
|
||||||
|
5 snapshots for the unconditional `@ailang_int_to_str` /
|
||||||
|
`@ailang_float_to_str` declares).
|
||||||
|
|
||||||
|
Task 5 repaired the bug-fixture / pin-test collision: the
|
||||||
|
`bugfix-instance-body-unbound-var` iter (commit 77f584a) used
|
||||||
|
`str_concat` as the literal unbound name because that was the
|
||||||
|
LLM-natural shape the fieldtester reached for; renamed to
|
||||||
|
`format_label` (LLM-author-realistic helper name) to preserve the
|
||||||
|
pin's intent (instance-method-body walked through unbound-var check)
|
||||||
|
while substituting a name that will never become a builtin.
|
||||||
|
|
||||||
|
## Per-task notes
|
||||||
|
|
||||||
|
- iter str-concat.1: RED fixture `examples/show_user_adt_with_label.ail`
|
||||||
|
(single-ctor `Item Int` ADT + `Show Item` body producing labelled
|
||||||
|
output) + E2E pin `crates/ail/tests/str_concat_e2e.rs` (check +
|
||||||
|
build + run asserting `Item 42\n` stdout). Confirmed RED with
|
||||||
|
`[unbound-var] prelude.Show: unknown identifier: 'str_concat'`.
|
||||||
|
- iter str-concat.2: `runtime/str.c` C helper `ailang_str_concat`
|
||||||
|
appended after `ailang_str_clone`; reads `len` from both source
|
||||||
|
payloads, `str_alloc(la+lb)`, memcpys both bytes, NUL-terminates.
|
||||||
|
Clean `clang -O2 -c` compile.
|
||||||
|
- iter str-concat.3: `crates/ailang-check/src/builtins.rs` —
|
||||||
|
`env.globals.insert("str_concat", ...)` install entry after
|
||||||
|
str_clone block (arity-2, both Borrow, Own return); list() row
|
||||||
|
`("str_concat", "(Str, Str) -> Str")` after str_clone row;
|
||||||
|
`install_str_concat_signature` test reaches into `env.globals`
|
||||||
|
directly to match the arity-2 / param_modes / ret_mode discipline
|
||||||
|
(deeper than the sibling `synth_in_builtins_env`-based tests
|
||||||
|
because str_concat is the first builtin with arity-2 Borrow params).
|
||||||
|
- iter str-concat.4: `crates/ailang-codegen/src/lib.rs` four-site lockstep:
|
||||||
|
extern declaration after `@ailang_str_clone`; lower_app arm after
|
||||||
|
str_clone arm (arity-2 check + two `lower_term` + `fresh_ssa` +
|
||||||
|
body push); `is_builtin_callable` list extended with `"str_concat"`;
|
||||||
|
IR-pin test mirrors the sibling `str_clone_emits_call_to_ailang_str_clone`
|
||||||
|
pattern (Module/FnDef/Term::Do scaffolding via `super::*` +
|
||||||
|
`SCHEMA`, not the plan's `ailang_surface::parse` structural sketch —
|
||||||
|
plan explicitly authorised this in Step 4 note). IR-pin test
|
||||||
|
asserts both the extern declare AND the call site. RED→GREEN flip
|
||||||
|
on the E2E pin happens at this task. Five IR snapshots regenerated
|
||||||
|
via `UPDATE_SNAPSHOTS=1` to absorb the new declare line (sole
|
||||||
|
diff verified at line 17 across all snapshots).
|
||||||
|
- iter str-concat.5: bug-fixture / pin-test collision repaired —
|
||||||
|
`examples/bug_unbound_in_instance_method.ail` line 14 rename
|
||||||
|
`str_concat` → `format_label`; pin test
|
||||||
|
`crates/ail/tests/unbound_in_instance_method_pin.rs` `replace_all`
|
||||||
|
rename (one test name changed to
|
||||||
|
`check_fires_unbound_var_for_format_label_in_instance_method_body`).
|
||||||
|
Both pin tests pass; new fixture `ail check` reports
|
||||||
|
`ok (23 symbols across 2 modules)` — matches the plan's empirical
|
||||||
|
guess exactly.
|
||||||
|
- iter str-concat.6: `docs/DESIGN.md` new §"Heap-Str primitives"
|
||||||
|
subsection between the milestone-24 paragraph and the
|
||||||
|
`Primitive output goes through ...` paragraph; lists all five
|
||||||
|
shipping primitives with their type, iter origin, and prelude
|
||||||
|
role, plus the user-visible vs prelude-internal distinction.
|
||||||
|
- iter str-concat.7: `docs/roadmap.md` struck `[x] [feature] str_concat`
|
||||||
|
entry at end of P2 cluster (right before `## P3 — Ideas`). All
|
||||||
|
four cited fixtures (`show_user_adt_with_label`, `show_user_adt`,
|
||||||
|
`test_22c_user_class_e2e`, `cmp_max_smoke`) `ail check` clean.
|
||||||
|
`ail builtins | grep str_concat` returns `str_concat (Str, Str) -> Str`.
|
||||||
|
|
||||||
|
## Concerns
|
||||||
|
|
||||||
|
- Task 3 (Step 5) test-count prediction was off: plan expected
|
||||||
|
`passed: 560 failed: 1` after registering the builtin but actual
|
||||||
|
was `passed: 558 failed: 3`. Reason: the two
|
||||||
|
`unbound_in_instance_method_pin` tests turn RED as soon as
|
||||||
|
`str_concat` is registered in the checker, NOT only at codegen.
|
||||||
|
The pins assert on `ail check`'s `[unbound-var]` diagnostic, which
|
||||||
|
depends only on the checker registry. Implementation is correct;
|
||||||
|
Task 5 fully repairs. No follow-up needed beyond noting the
|
||||||
|
prediction-vs-actual gap.
|
||||||
|
- Task 4 (Step 7) test-count prediction was off: plan expected
|
||||||
|
`passed: 562 failed: 1` after codegen lands; actual after
|
||||||
|
IR-snapshot regen was `passed: 560 failed: 2`. The difference is
|
||||||
|
fully accounted for by the 2 still-RED pin tests (repaired in
|
||||||
|
T5) and by the IR-snapshot regen being out-of-band of the
|
||||||
|
per-task counters in the plan. Final state at T5 Step 5 is
|
||||||
|
exactly `passed: 562 failed: 0`, matching the plan's iter target.
|
||||||
|
- IR snapshot regeneration was not explicitly scripted in the plan
|
||||||
|
for Task 4, but it follows the hs.4 precedent (which regenerated
|
||||||
|
the same 5 snapshots for the same reason: a new unconditional
|
||||||
|
declare line in the IR header). The sole diff in each snapshot
|
||||||
|
is the new `declare ptr @ailang_str_concat(ptr, ptr)` line on
|
||||||
|
IR line 17, immediately before the existing
|
||||||
|
`declare i64 @llvm.fptosi.sat.i64.f64(double)` declaration.
|
||||||
|
|
||||||
|
## Known debt
|
||||||
|
|
||||||
|
- (none)
|
||||||
|
|
||||||
|
## Files touched
|
||||||
|
|
||||||
|
Code:
|
||||||
|
- `runtime/str.c` (M) — append `ailang_str_concat` helper
|
||||||
|
- `crates/ailang-check/src/builtins.rs` (M) — install entry, list()
|
||||||
|
row, signature unit test
|
||||||
|
- `crates/ailang-codegen/src/lib.rs` (M) — extern declare,
|
||||||
|
lower_app arm, is_builtin_callable list, IR-pin unit test
|
||||||
|
- `crates/ail/tests/str_concat_e2e.rs` (new) — E2E pin
|
||||||
|
- `crates/ail/tests/unbound_in_instance_method_pin.rs` (M) — rename
|
||||||
|
`str_concat` → `format_label` (4 sites + 1 test name)
|
||||||
|
- `examples/show_user_adt_with_label.ail` (new) — Show body fixture
|
||||||
|
- `examples/bug_unbound_in_instance_method.ail` (M) — rename
|
||||||
|
`str_concat` → `format_label` (1 site)
|
||||||
|
|
||||||
|
IR snapshots regenerated (5; sole diff = new declare line):
|
||||||
|
- `crates/ail/tests/snapshots/hello.ll`
|
||||||
|
- `crates/ail/tests/snapshots/list.ll`
|
||||||
|
- `crates/ail/tests/snapshots/max3.ll`
|
||||||
|
- `crates/ail/tests/snapshots/sum.ll`
|
||||||
|
- `crates/ail/tests/snapshots/ws_main.ll`
|
||||||
|
|
||||||
|
Docs:
|
||||||
|
- `docs/DESIGN.md` (M) — new §"Heap-Str primitives" subsection
|
||||||
|
- `docs/roadmap.md` (M) — struck `[x] [feature] str_concat` line
|
||||||
|
|
||||||
|
## Stats
|
||||||
|
|
||||||
|
bench/orchestrator-stats/2026-05-13-iter-str-concat.json
|
||||||
@@ -328,6 +328,15 @@ context. Pick the next milestone from P1.)_
|
|||||||
other consumer survives.
|
other consumer survives.
|
||||||
- context: JOURNAL 2026-05-11 ("Iteration ct.4") — milestone close
|
- context: JOURNAL 2026-05-11 ("Iteration ct.4") — milestone close
|
||||||
follow-up; closed by iter ctt.1 — recon confirmed the rebuild is the load-bearing consumer of in-band DuplicateCtor (pinned by `crates/ailang-check/tests/duplicate_ctor_pin.rs`); the asymmetry with the mono side is intentional.
|
follow-up; closed by iter ctt.1 — recon confirmed the rebuild is the load-bearing consumer of in-band DuplicateCtor (pinned by `crates/ailang-check/tests/duplicate_ctor_pin.rs`); the asymmetry with the mono side is intentional.
|
||||||
|
- [x] **\[feature\]** `str_concat : (borrow Str, borrow Str) -> Str` —
|
||||||
|
heap-Str concatenation primitive. Shipped 2026-05-13 as iter
|
||||||
|
str-concat (closes fieldtest-form-a friction #4). Symmetric to the
|
||||||
|
iter 24.1 `str_clone` / `int_to_str` / `bool_to_str` plumbing:
|
||||||
|
runtime C helper (`ailang_str_concat`), `ailang-check` builtin
|
||||||
|
registration, `ailang-codegen` extern + `lower_app` arm, plus a
|
||||||
|
fresh `examples/show_user_adt_with_label.ail` corpus fixture
|
||||||
|
exercising the LLM-natural Show-body shape.
|
||||||
|
- context: per-iter journal 2026-05-13-iter-str-concat.md.
|
||||||
|
|
||||||
## P3 — Ideas
|
## P3 — Ideas
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
(lam
|
(lam
|
||||||
(params (typed n (con Int)))
|
(params (typed n (con Int)))
|
||||||
(ret (con Str))
|
(ret (con Str))
|
||||||
(body (app str_concat "n=" (app int_to_str n)))))))
|
(body (app format_label "n=" (app int_to_str n)))))))
|
||||||
(fn main
|
(fn main
|
||||||
(type (fn-type (params) (ret (con Unit)) (effects IO)))
|
(type (fn-type (params) (ret (con Unit)) (effects IO)))
|
||||||
(params)
|
(params)
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
(module show_user_adt_with_label
|
||||||
|
(data Item
|
||||||
|
(ctor MkItem (con Int)))
|
||||||
|
(instance
|
||||||
|
(class prelude.Show)
|
||||||
|
(type (con Item))
|
||||||
|
(method show
|
||||||
|
(body (lam (params (typed it (con Item))) (ret (con Str)) (body (match it
|
||||||
|
(case (pat-ctor MkItem n) (app str_concat "Item " (app int_to_str n)))))))))
|
||||||
|
(fn main
|
||||||
|
(type (fn-type (params) (ret (con Unit)) (effects IO)))
|
||||||
|
(params)
|
||||||
|
(body (app print (term-ctor Item MkItem 42)))))
|
||||||
@@ -184,3 +184,30 @@ char *ailang_str_clone(const char *src) {
|
|||||||
payload[8 + len] = '\0';
|
payload[8 + len] = '\0';
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* `ailang_str_concat(a, b)` — heap-Str concatenation primitive
|
||||||
|
* shipped in iter str-concat (2026-05-13). Reads `len` headers from
|
||||||
|
* both source Str payloads (offset 0 of each), allocates a fresh
|
||||||
|
* heap-Str slab sized for the combined bytes via `str_alloc`,
|
||||||
|
* memcpys both payloads in order, NUL-terminates, returns the new
|
||||||
|
* payload pointer. Like `str_clone`, this works uniformly on static-
|
||||||
|
* Str and heap-Str inputs because the consumer ABI is identical
|
||||||
|
* between realisations (see DESIGN.md §"Heap-Str primitives").
|
||||||
|
*
|
||||||
|
* Used by Show bodies that want labelled output (e.g.
|
||||||
|
* `"Item " ++ int_to_str n`) and by any caller that needs to
|
||||||
|
* combine two existing Str values into a new owned Str.
|
||||||
|
*
|
||||||
|
* Returns the payload pointer of a fresh heap-Str slab. The rc
|
||||||
|
* header is initialised to 1 by `str_alloc`.
|
||||||
|
*/
|
||||||
|
char *ailang_str_concat(const char *a, const char *b) {
|
||||||
|
uint64_t la = *(const uint64_t *)a;
|
||||||
|
uint64_t lb = *(const uint64_t *)b;
|
||||||
|
char *payload = str_alloc(la + lb);
|
||||||
|
memcpy(payload + 8, a + 8, la);
|
||||||
|
memcpy(payload + 8 + la, b + 8, lb);
|
||||||
|
payload[8 + la + lb] = '\0';
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user