diff --git a/bench/orchestrator-stats/2026-05-13-iter-str-concat.json b/bench/orchestrator-stats/2026-05-13-iter-str-concat.json new file mode 100644 index 0000000..7f34613 --- /dev/null +++ b/bench/orchestrator-stats/2026-05-13-iter-str-concat.json @@ -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 +} diff --git a/crates/ail/tests/snapshots/hello.ll b/crates/ail/tests/snapshots/hello.ll index 21b7739..b6f4d02 100644 --- a/crates/ail/tests/snapshots/hello.ll +++ b/crates/ail/tests/snapshots/hello.ll @@ -14,6 +14,7 @@ declare ptr @ailang_int_to_str(i64) declare ptr @ailang_float_to_str(double) declare ptr @ailang_bool_to_str(i1) declare ptr @ailang_str_clone(ptr) +declare ptr @ailang_str_concat(ptr, ptr) 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 } diff --git a/crates/ail/tests/snapshots/list.ll b/crates/ail/tests/snapshots/list.ll index 91334a1..4d9ece9 100644 --- a/crates/ail/tests/snapshots/list.ll +++ b/crates/ail/tests/snapshots/list.ll @@ -14,6 +14,7 @@ declare ptr @ailang_int_to_str(i64) declare ptr @ailang_float_to_str(double) declare ptr @ailang_bool_to_str(i1) declare ptr @ailang_str_clone(ptr) +declare ptr @ailang_str_concat(ptr, ptr) 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 } diff --git a/crates/ail/tests/snapshots/max3.ll b/crates/ail/tests/snapshots/max3.ll index ad83a7a..b7be9f9 100644 --- a/crates/ail/tests/snapshots/max3.ll +++ b/crates/ail/tests/snapshots/max3.ll @@ -14,6 +14,7 @@ declare ptr @ailang_int_to_str(i64) declare ptr @ailang_float_to_str(double) declare ptr @ailang_bool_to_str(i1) declare ptr @ailang_str_clone(ptr) +declare ptr @ailang_str_concat(ptr, ptr) 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 } diff --git a/crates/ail/tests/snapshots/sum.ll b/crates/ail/tests/snapshots/sum.ll index 3f616b0..f2cfdf8 100644 --- a/crates/ail/tests/snapshots/sum.ll +++ b/crates/ail/tests/snapshots/sum.ll @@ -14,6 +14,7 @@ declare ptr @ailang_int_to_str(i64) declare ptr @ailang_float_to_str(double) declare ptr @ailang_bool_to_str(i1) declare ptr @ailang_str_clone(ptr) +declare ptr @ailang_str_concat(ptr, ptr) 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 } diff --git a/crates/ail/tests/snapshots/ws_main.ll b/crates/ail/tests/snapshots/ws_main.ll index 819e412..256f571 100644 --- a/crates/ail/tests/snapshots/ws_main.ll +++ b/crates/ail/tests/snapshots/ws_main.ll @@ -14,6 +14,7 @@ declare ptr @ailang_int_to_str(i64) declare ptr @ailang_float_to_str(double) declare ptr @ailang_bool_to_str(i1) declare ptr @ailang_str_clone(ptr) +declare ptr @ailang_str_concat(ptr, ptr) 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 } diff --git a/crates/ail/tests/str_concat_e2e.rs b/crates/ail/tests/str_concat_e2e.rs new file mode 100644 index 0000000..8e3f93b --- /dev/null +++ b/crates/ail/tests/str_concat_e2e.rs @@ -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:?}" + ); +} diff --git a/crates/ail/tests/unbound_in_instance_method_pin.rs b/crates/ail/tests/unbound_in_instance_method_pin.rs index 4049286..38049d6 100644 --- a/crates/ail/tests/unbound_in_instance_method_pin.rs +++ b/crates/ail/tests/unbound_in_instance_method_pin.rs @@ -14,11 +14,11 @@ //! Pre-fix observed behaviour (the bug): //! - `ail check` exits 0 with `ok (23 symbols across 2 modules)`. //! - `ail build` exits 1 with -//! `Error: monomorphise_workspace: unknown identifier: \`str_concat\``. +//! `Error: monomorphise_workspace: unknown identifier: \`format_label\``. //! //! Post-fix expected behaviour: //! - `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): //! `crates/ailang-check/src/lib.rs::check_def` early-returns `Ok(())` @@ -30,9 +30,9 @@ //! method-body identifier graph. //! //! 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 -//! 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. use std::path::Path; @@ -43,13 +43,13 @@ fn ail_bin() -> &'static str { } /// 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. /// /// Pre-fix, this test fails because `ail check` exits 0 and prints /// `ok (23 symbols across 2 modules)`. #[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 workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap(); let src = workspace @@ -81,8 +81,8 @@ fn check_fires_unbound_var_for_str_concat_in_instance_method_body() { got: {combined}" ); assert!( - combined.contains("str_concat"), - "diagnostic must name the unbound identifier `str_concat`; \ + combined.contains("format_label"), + "diagnostic must name the unbound identifier `format_label`; \ got: {combined}" ); assert!( @@ -128,9 +128,9 @@ fn check_json_unbound_var_in_instance_method_body() { arr.iter().any(|d| { d.get("severity").and_then(|v| v.as_str()) == Some("error") && 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 \ - `str_concat`; got: {stdout}" + `format_label`; got: {stdout}" ); } diff --git a/crates/ailang-check/src/builtins.rs b/crates/ailang-check/src/builtins.rs index 67888c2..75d7cd0 100644 --- a/crates/ailang-check/src/builtins.rs +++ b/crates/ailang-check/src/builtins.rs @@ -230,6 +230,19 @@ pub fn install(env: &mut crate::Env) { 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( "is_nan".into(), Type::Fn { @@ -328,6 +341,7 @@ pub fn list() -> Vec<(&'static str, &'static str)> { ("int_to_str", "(Int) -> Str"), ("bool_to_str", "(Bool) -> Str"), ("str_clone", "(Str) -> Str"), + ("str_concat", "(Str, Str) -> Str"), ("is_nan", "(Float) -> Bool"), ("nan", "Float"), ("inf", "Float"), @@ -513,6 +527,43 @@ mod tests { 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 /// `fcmp uno double %x, %x` in iter 4 — typecheck only validates /// the signature here. diff --git a/crates/ailang-codegen/src/lib.rs b/crates/ailang-codegen/src/lib.rs index 6ea6f29..b3d2bb7 100644 --- a/crates/ailang-codegen/src/lib.rs +++ b/crates/ailang-codegen/src/lib.rs @@ -546,6 +546,7 @@ fn lower_workspace_inner(ws: &Workspace, alloc: AllocStrategy) -> Result // 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}" + ); + } } diff --git a/docs/DESIGN.md b/docs/DESIGN.md index f113f96..5bd6375 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -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 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_`, +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 ` 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` / `io/print_str` directly. diff --git a/docs/journals/2026-05-13-iter-str-concat.md b/docs/journals/2026-05-13-iter-str-concat.md new file mode 100644 index 0000000..018782f --- /dev/null +++ b/docs/journals/2026-05-13-iter-str-concat.md @@ -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 diff --git a/docs/roadmap.md b/docs/roadmap.md index 4cca72f..82920f2 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -328,6 +328,15 @@ context. Pick the next milestone from P1.)_ other consumer survives. - 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. +- [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 diff --git a/examples/bug_unbound_in_instance_method.ail b/examples/bug_unbound_in_instance_method.ail index efe9a86..52ef154 100644 --- a/examples/bug_unbound_in_instance_method.ail +++ b/examples/bug_unbound_in_instance_method.ail @@ -11,7 +11,7 @@ (lam (params (typed n (con Int))) (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 (type (fn-type (params) (ret (con Unit)) (effects IO))) (params) diff --git a/examples/show_user_adt_with_label.ail b/examples/show_user_adt_with_label.ail new file mode 100644 index 0000000..4387144 --- /dev/null +++ b/examples/show_user_adt_with_label.ail @@ -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))))) diff --git a/runtime/str.c b/runtime/str.c index 93e3778..b9b064a 100644 --- a/runtime/str.c +++ b/runtime/str.c @@ -184,3 +184,30 @@ char *ailang_str_clone(const char *src) { payload[8 + len] = '\0'; 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; +}