Files
AILang/docs/plans/0062-iter-str-concat.md
T
Brummel 832375f2ac convention: counter-prefix file naming across docs/specs/, docs/plans/, design/contracts/, design/models/
All 176 files in the four accumulating directories now use a
zero-padded 4-digit counter prefix that reflects creation order
(`NNNN-slug.md`). The counter is assigned per directory in strict
git-log creation order; ties broken alphabetically by original name.
The old `YYYY-MM-DD-` prefix on docs/specs/ and docs/plans/ files is
dropped — the date is recoverable from git log and the counter
carries the ordering.

A file's counter is stable for the life of the file: never reassigned,
never reused, never compacted. Deleted files retire their counter;
subsequent files do not fill the gap. This is the property that lets
cross-references stay literal — refs use the full filename including
the counter (`design/contracts/0007-honesty-rule.md`) so they grep
cleanly and resolve directly without a glob step.

313 cross-references updated across .md/.rs/.toml/.c/.json files
(test pins, include_str! paths, design-INDEX entries, baseline notes,
runtime C comments, inter-contract markdown links incl. bare basename
and `../models/foo.md` forms).

CLAUDE.md gets a new "File-naming convention" section spelling out
the rule and rationale. skills/brainstorm/SKILL.md and
skills/planner/SKILL.md updated so new spec/plan creation produces
counter-prefixed names from the start.

The full test suite (cargo test --workspace) passes.
2026-05-28 13:31:31 +02:00

569 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# str-concat — Implementation Plan
> **Parent spec:** `docs/specs/0026-fieldtest-form-a.md` §"[friction] No primitive str_concat / str_append in builtins" (lines 186-212).
>
> **For agentic workers:** REQUIRED SUB-SKILL: use `skills/implement` to run this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Add `str_concat : (borrow Str, borrow Str) -> Str` as a runtime + checker + codegen primitive in the four-site-lockstep pattern established by `str_clone` / `int_to_str` / `bool_to_str` (iter 24.1), so the LLM-natural Show-MyType body `(app str_concat "label=" (app int_to_str x))` parses, checks, builds, and runs.
**Architecture:** Four-site lockstep registration mirroring `str_clone` (iter 24.1). The new primitive ships at: (1) `runtime/str.c` as a C helper `ailang_str_concat(const char *a, const char *b) -> char *` that slab-allocates a fresh heap-Str of size `a.len + b.len`, memcpys both payloads, terminates; (2) `crates/ailang-check/src/builtins.rs::install` as a `Type::Fn { params: 2× Str borrow, ret: Str own }` entry plus a `list()` row plus a `install_str_concat_signature` unit test; (3) `crates/ailang-codegen/src/lib.rs` as an `extern @ailang_str_concat(ptr, ptr)` declaration plus an arm in `lower_app` emitting the call plus an entry in `is_builtin_callable` plus an IR-pin unit test; (4) `examples/show_user_adt_with_label.ail` corpus fixture authored fresh (the LLM-natural shape the fieldtester first reached for) plus a `crates/ail/tests/str_concat_e2e.rs` E2E test asserting check + build + run produce the expected stdout. The plan also repairs one lockstep collision: `examples/bug_unbound_in_instance_method.ail` currently uses `str_concat` as its UNBOUND name (because that was the literal name the fieldtester tried); after str_concat ships as a builtin the fixture and its pin test `crates/ail/tests/unbound_in_instance_method_pin.rs` would silently turn into a false-OK. Task 5 renames the unbound name to `format_label` (LLM-author-realistic fn name) and updates the pin test accordingly, preserving the regression guard's intent (instance-method-body walked through unbound-var check) while substituting a name that will never become a builtin.
**Tech Stack:** Rust (`crates/ailang-check`, `crates/ailang-codegen`, `crates/ail/tests`), C (`runtime/str.c`), Markdown (`docs/DESIGN.md`, `docs/roadmap.md`), `.ail` Form-A (`examples/`).
---
**Files this plan creates or modifies:**
- Create: `examples/show_user_adt_with_label.ail` — Show user-ADT with str_concat-using body (Task 1)
- Create: `crates/ail/tests/str_concat_e2e.rs` — E2E pin asserting check + build + run on the new fixture (Task 1)
- Modify: `runtime/str.c:186+` — append `ailang_str_concat` C helper (Task 2)
- Modify: `crates/ailang-check/src/builtins.rs:232` — insert str_concat registration after str_clone (Task 3)
- Modify: `crates/ailang-check/src/builtins.rs:330` — insert list() row after str_clone (Task 3)
- Modify: `crates/ailang-check/src/builtins.rs:505-514` — insert signature unit test after install_str_clone_signature (Task 3)
- Modify: `crates/ailang-codegen/src/lib.rs:548` — append `@ailang_str_concat` extern declaration after str_clone declare (Task 4)
- Modify: `crates/ailang-codegen/src/lib.rs:1977` — insert str_concat arm in lower_app after str_clone arm (Task 4)
- Modify: `crates/ailang-codegen/src/lib.rs:2192` — extend is_builtin_callable name-list (Task 4)
- Modify: `crates/ailang-codegen/src/lib.rs:4255-4292` — append IR-pin test after str_clone IR-pin test (Task 4)
- Modify: `examples/bug_unbound_in_instance_method.ail:14` — rename `str_concat` to `format_label` (Task 5)
- Modify: `crates/ail/tests/unbound_in_instance_method_pin.rs` — rename str_concat references to format_label (Task 5)
- Modify: `docs/DESIGN.md:1992` — insert new §"Heap-Str primitives" subsection (Task 6)
- Modify: `docs/roadmap.md` — add struck-out `[x] [feature] str_concat primitive` line (Task 7)
---
### Task 1: RED — fixture + E2E test for str_concat
**Files:**
- Create: `examples/show_user_adt_with_label.ail`
- Create: `crates/ail/tests/str_concat_e2e.rs`
- [ ] **Step 1: Author the fresh `.ail` fixture exercising str_concat in a Show body**
Create `examples/show_user_adt_with_label.ail` with content:
```
(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)))))
```
This is the LLM-natural shape: a single-ctor ADT with an integer payload, a Show instance whose body produces a labelled string (`"Item 42"`) by concatenating a static-Str prefix with the int_to_str rendering. The output via `print` (polymorphic from iter 24.3) goes through the prelude's `print : forall a. Show a => (a borrow) -> Unit !IO`.
- [ ] **Step 2: Author the E2E test file**
Create `crates/ail/tests/str_concat_e2e.rs` with content:
```rust
//! 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:?}"
);
}
```
- [ ] **Step 3: Run the RED test to confirm it fails today**
Run: `cargo test --manifest-path=Cargo.toml -p ail --test str_concat_e2e 2>&1 | tail -15`
Expected: FAIL with `ail check must succeed` panic, because `str_concat` is unbound. Specifically the diagnostic surface should mention `[unbound-var]` and `str_concat`. The test must fail because of this RED, not because the fixture parse-fails or the binary spawn fails — verify by reading the panic output.
- [ ] **Step 4: Full workspace test as a no-regression smoke**
Run: `cargo test --manifest-path=Cargo.toml --workspace 2>&1 | grep -E "^test result:" | awk '{passed+=$4; failed+=$6} END {print "passed:",passed,"failed:",failed}'`
Expected: `passed: 559 failed: 1` (or 560 passed 1 failed depending on whether the new pin counts; the only failing test must be `str_concat_e2e_show_user_adt_with_label`).
### Task 2: Runtime C helper
**Files:**
- Modify: `runtime/str.c` — append after `ailang_str_clone` (currently ends at line 186)
- [ ] **Step 1: Append the C helper implementation**
After the closing brace of `ailang_str_clone` at line 186 of `runtime/str.c`, append:
```c
/*
* `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;
}
```
- [ ] **Step 2: Verify the C file compiles**
Run: `clang -O2 -c runtime/str.c -o /tmp/str.o 2>&1 | head -20`
Expected: no output (clean compile).
- [ ] **Step 3: Full workspace test as a no-regression smoke**
Run: `cargo test --manifest-path=Cargo.toml --workspace 2>&1 | grep -E "^test result:" | awk '{passed+=$4; failed+=$6} END {print "passed:",passed,"failed:",failed}'`
Expected: `passed: 559 failed: 1` (str_concat_e2e still RED — check still rejects, can't reach build).
### Task 3: ailang-check builtin registration
**Files:**
- Modify: `crates/ailang-check/src/builtins.rs:232` — insert install entry after the `str_clone` block (currently ends at line 232)
- Modify: `crates/ailang-check/src/builtins.rs:330` — append list() row after `("str_clone", ...)`
- Modify: `crates/ailang-check/src/builtins.rs:514` — append signature test after `install_str_clone_signature`
- [ ] **Step 1: Insert str_concat install entry**
Immediately after the closing `);` of the `str_clone` `env.globals.insert(...)` block at `crates/ailang-check/src/builtins.rs:232`, insert:
```rust
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,
},
);
```
- [ ] **Step 2: Append list() row**
Find the row `("str_clone", "(Str) -> Str"),` near `crates/ailang-check/src/builtins.rs:330`. Insert the new row immediately after it:
```rust
("str_concat", "(Str, Str) -> Str"),
```
Maintain the comma + indentation pattern of the surrounding rows.
- [ ] **Step 3: Append signature unit test**
Find `install_str_clone_signature` around `crates/ailang-check/src/builtins.rs:505-514`. Immediately after its closing `}`, append:
```rust
#[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:?}"),
}
}
```
- [ ] **Step 4: Run the new unit test**
Run: `cargo test --manifest-path=Cargo.toml -p ailang-check builtins::tests::install_str_concat_signature 2>&1 | tail -5`
Expected: `test result: ok. 1 passed`.
- [ ] **Step 5: Full workspace test**
Run: `cargo test --manifest-path=Cargo.toml --workspace 2>&1 | grep -E "^test result:" | awk '{passed+=$4; failed+=$6} END {print "passed:",passed,"failed:",failed}'`
Expected: `passed: 560 failed: 1` (str_concat_e2e moves further but still RED — check passes now, build fails because codegen doesn't yet emit the call).
### Task 4: ailang-codegen — extern declare + lower_app arm + is_builtin_callable + IR pin
**Files:**
- Modify: `crates/ailang-codegen/src/lib.rs:548` — append extern declaration
- Modify: `crates/ailang-codegen/src/lib.rs:1977` — insert lower_app arm after str_clone arm
- Modify: `crates/ailang-codegen/src/lib.rs:2192` — extend is_builtin_callable list
- Modify: `crates/ailang-codegen/src/lib.rs:4292` — append IR-pin test after str_clone IR-pin
- [ ] **Step 1: Append extern declaration**
Find the line `out.push_str("declare ptr @ailang_str_clone(ptr)\n");` at `crates/ailang-codegen/src/lib.rs:548`. Immediately after that push_str line, insert:
```rust
out.push_str("declare ptr @ailang_str_concat(ptr, ptr)\n");
```
- [ ] **Step 2: Insert str_concat arm in lower_app after the str_clone arm**
Find the str_clone arm at `crates/ailang-codegen/src/lib.rs:1960-1977`. Immediately after its closing brace (the `}` at line 1977), insert:
```rust
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()));
}
```
- [ ] **Step 3: Extend is_builtin_callable name list**
Find the `matches!` block at `crates/ailang-codegen/src/lib.rs:2183-2193`:
```rust
if matches!(
name,
"neg"
| "int_to_float"
| "float_to_int_truncate"
| "is_nan"
| "float_to_str"
| "int_to_str"
| "bool_to_str"
| "str_clone"
) {
```
Change to:
```rust
if matches!(
name,
"neg"
| "int_to_float"
| "float_to_int_truncate"
| "is_nan"
| "float_to_str"
| "int_to_str"
| "bool_to_str"
| "str_clone"
| "str_concat"
) {
```
- [ ] **Step 4: Append IR-pin unit test**
Find the `str_clone_emits_call_to_ailang_str_clone` test at `crates/ailang-codegen/src/lib.rs:4255-4292`. Immediately after its closing `}`, append:
```rust
#[test]
fn str_concat_emits_call_to_ailang_str_concat() {
// Iter str-concat: `(app str_concat "x" "y")` must lower to
// `call ptr @ailang_str_concat(ptr, ptr)` in the emitted IR.
// Pins the extern declaration + the lower_app arm together.
let src = r#"
(module ir_pin_str_concat
(fn produce_label
(type (fn-type (params) (ret (con Str))))
(params)
(body (app str_concat "x" "y"))))
"#;
let module: Module = serde_json::from_str(
&ailang_surface::parse(src).expect("parse").to_string(),
)
.expect("module from parse");
let mut ws = Workspace::default();
ws.modules
.insert(module.name.clone(), module.clone());
let ir = lower_workspace_to_ir(&ws, &CodegenOptions::default())
.expect("codegen");
assert!(
ir.contains("declare ptr @ailang_str_concat(ptr, ptr)"),
"IR must declare the extern; got {ir}"
);
assert!(
ir.contains("call ptr @ailang_str_concat(ptr"),
"IR must contain the call to @ailang_str_concat; got {ir}"
);
}
```
(If the imports in the surrounding test module differ from what's used by the str_clone IR pin, lift them — read 4255-4292 first and mirror the pattern exactly. The above is a structural sketch; the exact `Module` / `Workspace` / `lower_workspace_to_ir` imports must match the sibling test.)
- [ ] **Step 5: Run the new IR-pin test**
Run: `cargo test --manifest-path=Cargo.toml -p ailang-codegen str_concat_emits_call_to_ailang_str_concat 2>&1 | tail -5`
Expected: `test result: ok. 1 passed`.
- [ ] **Step 6: Run the str_concat E2E test**
Run: `cargo test --manifest-path=Cargo.toml -p ail --test str_concat_e2e 2>&1 | tail -5`
Expected: `test result: ok. 1 passed`. The RED from Task 1 is now GREEN: check passes, build passes, run produces `Item 42\n`.
- [ ] **Step 7: Full workspace test**
Run: `cargo test --manifest-path=Cargo.toml --workspace 2>&1 | grep -E "^test result:" | awk '{passed+=$4; failed+=$6} END {print "passed:",passed,"failed:",failed}'`
Expected: `passed: 562 failed: 1` (the new pin test + new IR-pin test pass; but the `unbound_in_instance_method_pin.rs` tests now FAIL because str_concat is no longer unbound — Task 5 repairs this lockstep collision).
### Task 5: Repair the bug-fixture / pin-test collision
**Files:**
- Modify: `examples/bug_unbound_in_instance_method.ail:14` — rename `str_concat` to `format_label`
- Modify: `crates/ail/tests/unbound_in_instance_method_pin.rs` — rename all four `str_concat` references to `format_label`
**Why this task exists.** 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 actually reached for. Once str_concat ships as a builtin (Tasks 1-4), the fixture would silently turn into a false-OK and the pin test starts failing. The pin's intent (instance-method-body walked through unbound-var check) is name-independent; substituting a different LLM-natural unbound name (`format_label` — the kind of helper an LLM-author would forget to define) preserves the intent.
- [ ] **Step 1: Update the fixture**
Open `examples/bug_unbound_in_instance_method.ail`. The current line 14 is:
```
(body (app str_concat "n=" (app int_to_str n)))))))
```
Replace `str_concat` with `format_label`:
```
(body (app format_label "n=" (app int_to_str n)))))))
```
- [ ] **Step 2: Update the pin test references**
Open `crates/ail/tests/unbound_in_instance_method_pin.rs`. The current file has multiple references to `str_concat`:
- Line 17: in the module docstring's "monomorphise_workspace: unknown identifier: `str_concat`" verbatim panic-message quote;
- Line 22: in the module docstring's "the unbound `str_concat` in the method body";
- Line 36: in the module docstring's fixture description "The fixture uses `str_concat` (NOT a builtin)";
- Line 52 onward: the test name `check_fires_unbound_var_for_str_concat_in_instance_method_body`;
- Line 84: assertion `combined.contains("str_concat")`;
- Line 100 onward: the test name `check_json_unbound_var_in_instance_method_body`;
- Line 132: assertion `d.to_string().contains("str_concat")`.
Strategy: replace `str_concat``format_label` everywhere in the file (Edit with `replace_all: true`). The test names will become `check_fires_unbound_var_for_format_label_in_instance_method_body` and `check_json_unbound_var_in_instance_method_body` (the second name doesn't contain `str_concat`; leave it). Update the docstring text accordingly.
The simpler exact strategy: read the file, then issue ONE Edit with `old_string = "str_concat"`, `new_string = "format_label"`, `replace_all = true`.
- [ ] **Step 3: Verify the pin test now correctly RED-protects with the new name**
Run: `cargo test --manifest-path=Cargo.toml -p ail --test unbound_in_instance_method_pin 2>&1 | tail -10`
Expected: `test result: ok. 2 passed`. Both pin tests pass: `ail check` correctly fires `[unbound-var]: format_label` on the fixture (the bugfix from 77f584a stays GREEN under the new unbound name).
- [ ] **Step 4: Verify the new fixture also `ail check`s as expected (its str_concat usage is now valid)**
Run: `./target/debug/ail check examples/show_user_adt_with_label.ail 2>&1`
Expected output: `ok (<N> symbols across 2 modules)` (likely `23 symbols across 2 modules`; verify empirically and record in the journal if it differs from expected).
- [ ] **Step 5: Full workspace test**
Run: `cargo test --manifest-path=Cargo.toml --workspace 2>&1 | grep -E "^test result:" | awk '{passed+=$4; failed+=$6} END {print "passed:",passed,"failed:",failed}'`
Expected: `passed: 562 failed: 0`. Net: +3 from baseline (559 → 562): the new E2E pin + the new builtin-signature test + the new IR-pin test, all GREEN.
### Task 6: DESIGN.md — new §"Heap-Str primitives" subsection
**Files:**
- Modify: `docs/DESIGN.md:1992` — insert new subsection between the milestone-24 amend block and the §"Primitive output goes through io/print_int" paragraph
- [ ] **Step 1: Insert new subsection**
Find the empty line after `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.` (DESIGN.md ending around line 1992). Before the `Primitive output goes through io/print_int / io/print_bool / io/print_str directly.` paragraph at line 1994, insert:
```markdown
### 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.
```
- [ ] **Step 2: Verify the DESIGN.md edit is syntactically sound**
Run: `grep -n '### Heap-Str primitives' docs/DESIGN.md`
Expected output: one hit on a line near 1993 or wherever the insertion landed.
Run: `grep -n 'Primitive output goes through' docs/DESIGN.md`
Expected output: one hit (the still-existing paragraph that followed the new subsection).
- [ ] **Step 3: Full workspace test as a no-regression smoke (pure docs change)**
Run: `cargo test --manifest-path=Cargo.toml --workspace 2>&1 | grep -E "^test result:" | awk '{passed+=$4; failed+=$6} END {print "passed:",passed,"failed:",failed}'`
Expected: `passed: 562 failed: 0`.
### Task 7: Verification + roadmap update
**Files:**
- Modify: `docs/roadmap.md` — insert a struck `[x]` line under a P2 block
- [ ] **Step 1: Add roadmap entry**
Open `docs/roadmap.md`. Find an appropriate place under P2 — alongside the other shipped features. The cleanest insertion point is at the END of the P2 §`[x]` cluster (right after the existing struck-out entries for milestones already shipped: Heap-Str ABI, Post-22 Prelude, Form-A authoring surface, etc.). Insert:
```markdown
- [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.
```
- [ ] **Step 2: Verify the four fixtures cited in this plan all still ail-check cleanly**
Run each in sequence:
```
./target/debug/ail check examples/show_user_adt_with_label.ail
./target/debug/ail check examples/show_user_adt.ail
./target/debug/ail check examples/test_22c_user_class_e2e.ail
./target/debug/ail check examples/cmp_max_smoke.ail
```
Expected: each prints `ok (<N> symbols across <M> modules)` and exits 0. None should regress.
- [ ] **Step 3: Verify the `ail builtins` command now lists str_concat**
Run: `./target/debug/ail builtins 2>&1 | grep str_concat`
Expected output: one line of the form `str_concat : (Str, Str) -> Str` (or whatever the existing `ail builtins` format is — `int_to_str`'s and `str_clone`'s rows will dictate the exact shape).
- [ ] **Step 4: Final full-workspace test**
Run: `cargo test --manifest-path=Cargo.toml --workspace 2>&1 | grep -E "^test result:" | awk '{passed+=$4; failed+=$6, ignored+=$8} END {print "passed:",passed,"failed:",failed,"ignored:",ignored}'`
Expected: `passed: 562 failed: 0 ignored: 3`. Net iter delta: +3 tests from the 559 baseline (the E2E pin + builtin-signature test + IR-pin test).