plan: str-concat — str_concat heap-Str primitive (closes fieldtest-form-a friction #4)
Seven-task plan for adding 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).
T1: RED — new fixture examples/show_user_adt_with_label.ail
(LLM-natural Show body using str_concat) + E2E test
crates/ail/tests/str_concat_e2e.rs.
T2: runtime/str.c — append ailang_str_concat C helper after the
existing ailang_str_clone implementation (slab-alloc, memcpy
twice, terminate).
T3: ailang-check/src/builtins.rs — install + list() + new unit test
install_str_concat_signature; type is Fn { 2x Str borrow -> Str own,
effects empty }.
T4: ailang-codegen/src/lib.rs — extern declare ptr @ailang_str_concat
(ptr, ptr); lower_app arm after str_clone arm; extend
is_builtin_callable match list; append IR-pin unit test
str_concat_emits_call_to_ailang_str_concat.
T5: lockstep collision repair — rename str_concat -> format_label in
examples/bug_unbound_in_instance_method.ail and the corresponding
pin test (the fixture currently uses str_concat as the unbound
symbol because that was the literal fieldtester repro; after
shipping the builtin it would silently become a builtin call and
the pin would fail; format_label preserves the pin's intent under
an LLM-author-realistic name that will never become a builtin).
T6: DESIGN.md new §"Heap-Str primitives" subsection between the
milestone-24 amend block and the Primitive output paragraph,
cataloguing all 5 primitives (int_to_str, bool_to_str, float_to_str,
str_clone, str_concat) with one-line signatures + descriptions and
cross-references to runtime/str.c.
T7: verification + roadmap [x] strike entry.
Open questions resolved Boss-side ahead of dispatch:
- DESIGN.md anchor: new §"Heap-Str primitives" subsection (option (b)
in recon report) — the existing milestone-24 Show-backer enumeration
stays unchanged; the new section gives the heap-Str-primitive
category an explicit home.
- bug-fixture collision: rename to format_label (option (a) in recon
report) — LLM-author-realistic, preserves regression coverage.
Expected net delta: +3 tests from baseline (559 -> 562 green).
This commit is contained in:
@@ -0,0 +1,568 @@
|
||||
# str-concat — Implementation Plan
|
||||
|
||||
> **Parent spec:** `docs/specs/2026-05-13-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).
|
||||
Reference in New Issue
Block a user