plan: hs.4 — IR + checker + linker wiring, 4 tasks
Wires the hs.3 runtime symbols (ailang_int_to_str, ailang_float_to_str) into live AILang builtins. Four layers, one iter: checker install + synth.rs lockstep partner; IR-header declares + Emitter::lower_app arms (float arm replaces CodegenError::Internal, new int arm); rc.c hoisted to unconditional link (weak attr on str.c's extern becomes permanent no-op, retained intentionally); two new IR-shape pins + four new E2E (two stdout-smoke, two RC-stats discipline) + drop.rs comment refresh. Spec line refs were stale (hs.1+hs.2+hs.3 commits shifted them); plan anchors to recon-verified positions lib.rs:1890-1903 and :486-535. Edge cases (0, -1, i64::MAX, ±Inf, NaN) deferred to follow-up tidy iter if regression sweep does not surface need.
This commit is contained in:
@@ -0,0 +1,813 @@
|
||||
# hs.4 — IR + checker + linker wiring for int_to_str / float_to_str — Implementation Plan
|
||||
|
||||
> **Parent spec:** `docs/specs/2026-05-12-heap-str-abi.md`
|
||||
>
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: use `skills/implement`
|
||||
> to run this plan. Steps use `- [ ]` checkboxes for tracking.
|
||||
|
||||
**Goal:** Wire the hs.3 runtime symbols `ailang_int_to_str(int64_t)`
|
||||
and `ailang_float_to_str(double)` (already present in `runtime/str.c`
|
||||
since commit `1cf281b`) into live AILang builtins observable at every
|
||||
layer of the toolchain. After this iter a program
|
||||
`do io/print_str(int_to_str(42))` builds and prints `"42\n"`.
|
||||
|
||||
**Architecture:** Four layers, one iteration. (1) The checker installs
|
||||
`int_to_str : (Int) -> Str` as a builtin signature next to the existing
|
||||
`float_to_str`; the codegen-local replay table in `synth.rs` gets the
|
||||
parallel arm in lockstep. (2) The codegen emits `declare ptr
|
||||
@ailang_int_to_str(i64)` and `declare ptr @ailang_float_to_str(double)`
|
||||
in the unconditional IR-header preamble; the `Emitter::lower_app`
|
||||
builtin chain gets a new `int_to_str` arm and replaces the
|
||||
`CodegenError::Internal` body of the existing `float_to_str` arm with
|
||||
the actual `call` emission. (3) The build pipeline hoists
|
||||
`runtime/rc.c`'s clang-compile-and-link step out of the
|
||||
`AllocStrategy::Rc` arm into the unconditional path next to
|
||||
`runtime/str.c` (every binary links rc.c regardless of `--alloc`).
|
||||
(4) Six new tests pin the result: two IR-shape pins
|
||||
(`int_to_str_lowers_to_ailang_int_to_str_call`,
|
||||
`float_to_str_no_longer_errors_internal`), two stdout-smoke E2E
|
||||
(`int_to_str_smoke`, `float_to_str_smoke`), two RC-discipline E2E
|
||||
(`int_to_str_drop_balances_rc_stats`,
|
||||
`str_field_in_adt_drops_heap_str_correctly`). A stale comment in
|
||||
`crates/ailang-codegen/src/drop.rs` describing the static-Str-as-only
|
||||
realisation gets updated to the dual-realisation reality.
|
||||
|
||||
**Tech Stack:** Rust (`ailang-check`, `ailang-codegen`, `ail`),
|
||||
LLVM-IR string emission, JSON fixtures.
|
||||
|
||||
**Spec drift advisory:** The parent spec quotes line numbers
|
||||
`lib.rs:1829` (float_to_str arm) and `~:455-:498` (IR-header preamble).
|
||||
Hs.4 recon located the actual sites at `lib.rs:1890-1903` and
|
||||
`:486-535` respectively (hs.1 + hs.2 + hs.3 commits shifted them).
|
||||
This plan uses the recon-verified line numbers throughout.
|
||||
|
||||
---
|
||||
|
||||
## Files this plan creates or modifies
|
||||
|
||||
- Modify: `crates/ailang-check/src/builtins.rs:193-202` — add
|
||||
`int_to_str : (Int) -> Str` next to the existing `float_to_str`
|
||||
entry
|
||||
- Modify: `crates/ailang-check/src/builtins.rs:444-449` — add
|
||||
`install_int_to_str_signature` unit test mirroring
|
||||
`install_float_to_str_signature`
|
||||
- Modify: `crates/ailang-codegen/src/synth.rs:167-173` — add
|
||||
`int_to_str` arm to the local type-replay table (lockstep partner
|
||||
of the checker insert)
|
||||
- Modify: `crates/ailang-codegen/src/lib.rs:486-535` — IR-header
|
||||
preamble: add `declare ptr @ailang_int_to_str(i64)` and
|
||||
`declare ptr @ailang_float_to_str(double)` unconditionally
|
||||
- Modify: `crates/ailang-codegen/src/lib.rs:1890-1903` — the
|
||||
`float_to_str` arm in `Emitter::lower_app`: replace the
|
||||
`CodegenError::Internal` body with a `fresh_ssa` + call emission;
|
||||
add a new `int_to_str` arm parallel to it
|
||||
- Modify: `crates/ailang-codegen/src/drop.rs:374-381` — update the
|
||||
stale `Str` arm comment in `field_drop_call` to reflect the
|
||||
dual-realisation reality (heap-Str + static-Str share consumer ABI;
|
||||
static-Str pointers are kept out of `ailang_rc_dec` by codegen-level
|
||||
elision)
|
||||
- Modify: `crates/ail/src/main.rs:2327-2356` — hoist the
|
||||
`runtime/rc.c` compile-and-link out of the `AllocStrategy::Rc` arm
|
||||
into the unconditional path next to `runtime/str.c` at
|
||||
`:2271-2293`. After hoist, the `AllocStrategy::Rc` arm shrinks (no
|
||||
rc-specific linking remains; gc/bump arms unchanged)
|
||||
- Create: `crates/ailang-codegen/src/lib.rs` test module — two new
|
||||
IR-shape tests (`int_to_str_lowers_to_ailang_int_to_str_call`,
|
||||
`float_to_str_no_longer_errors_internal`) appended after the
|
||||
existing IR-shape tests
|
||||
- Create: `crates/ail/tests/e2e.rs` — four new E2E tests
|
||||
(`int_to_str_smoke`, `float_to_str_smoke`,
|
||||
`int_to_str_drop_balances_rc_stats`,
|
||||
`str_field_in_adt_drops_heap_str_correctly`) appended at the end
|
||||
- Create: `examples/int_to_str_smoke.ail.json` — `do io/print_str(int_to_str(42))`
|
||||
- Create: `examples/float_to_str_smoke.ail.json` — `do io/print_str(float_to_str(3.5))`
|
||||
- Create: `examples/int_to_str_drop_rc.ail.json` — N-loop of `int_to_str`
|
||||
with non-aliasing drops (RC-stats fixture)
|
||||
- Create: `examples/str_field_in_adt_heap.ail.json` — `type Boxed = | Box(Str)`
|
||||
carrying an `int_to_str` result, dropped at end of scope
|
||||
- Untouched: `runtime/*.c` — hs.3 already shipped the runtime symbols;
|
||||
`__attribute__((weak))` on `ailang_rc_alloc` in `str.c` becomes a
|
||||
permanent no-op after the rc.c hoist (strong definition wins),
|
||||
retained intentionally per the hs.3 journal Concerns section
|
||||
|
||||
---
|
||||
|
||||
## Task 1 — Install `int_to_str` in checker and synth.rs (lockstep type-signature pair)
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/ailang-check/src/builtins.rs:193-202` (insert),
|
||||
`:444-449` (test)
|
||||
- Modify: `crates/ailang-codegen/src/synth.rs:167-173` (insert)
|
||||
|
||||
- [ ] **Step 1: Add `int_to_str` signature in the checker**
|
||||
|
||||
Locate the `float_to_str` entry at
|
||||
`crates/ailang-check/src/builtins.rs:193-202`. Immediately above it
|
||||
(grouping `int_to_str` with `int_to_float` for the int-side cohort
|
||||
or immediately below it parallel to `float_to_str`; either placement
|
||||
is defensible — choose adjacent to `float_to_str` for the str-output
|
||||
grouping), insert:
|
||||
|
||||
```rust
|
||||
env.globals.insert(
|
||||
"int_to_str".into(),
|
||||
Type::Fn {
|
||||
params: vec![Type::int()],
|
||||
ret: Box::new(Type::str_()),
|
||||
effects: vec![],
|
||||
param_modes: vec![],
|
||||
ret_mode: ailang_core::ast::ParamMode::Implicit,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
Diff vs. `float_to_str`: only the name (`"int_to_str"`) and the
|
||||
param type (`Type::int()` instead of `Type::float()`) differ.
|
||||
|
||||
- [ ] **Step 2: Add `install_int_to_str_signature` unit test**
|
||||
|
||||
Locate the `install_float_to_str_signature` test at
|
||||
`crates/ailang-check/src/builtins.rs:442-449`. Append (or insert
|
||||
adjacent) the parallel int test:
|
||||
|
||||
```rust
|
||||
/// Iter hs.4: `int_to_str : (Int) -> Str`. Codegen lowers via the
|
||||
/// runtime C glue `ailang_int_to_str` from `runtime/str.c`.
|
||||
#[test]
|
||||
fn install_int_to_str_signature() {
|
||||
let ty = synth_in_builtins_env(&app("int_to_str", vec![lit_int(42)]));
|
||||
assert_eq!(ty, Type::str_(), "(int_to_str 42) must type as Str");
|
||||
}
|
||||
```
|
||||
|
||||
`lit_int` helper is already in scope (used by
|
||||
`install_int_to_float_signature` at line 425); confirm via
|
||||
`grep -n "fn lit_int" crates/ailang-check/src/builtins.rs` if unsure.
|
||||
|
||||
- [ ] **Step 3: Add `int_to_str` arm in synth.rs (lockstep partner)**
|
||||
|
||||
Locate the `float_to_str` arm at
|
||||
`crates/ailang-codegen/src/synth.rs:167-173`. Insert the parallel
|
||||
int arm immediately adjacent:
|
||||
|
||||
```rust
|
||||
"int_to_str" => Type::Fn {
|
||||
params: vec![Type::int()],
|
||||
ret: Box::new(Type::str_()),
|
||||
effects: vec![],
|
||||
param_modes: vec![],
|
||||
ret_mode: ParamMode::Implicit,
|
||||
},
|
||||
```
|
||||
|
||||
Diff vs. `float_to_str`: only the match-arm name and the param type
|
||||
differ. The file-level comment at synth.rs:140-141 explicitly names
|
||||
the lockstep with `builtins.rs` — Step 1 + Step 3 must land
|
||||
together; either-side absence causes checker-vs-codegen
|
||||
disagreement.
|
||||
|
||||
- [ ] **Step 4: Run the new unit test (RED — fails until codegen has
|
||||
lowering)**
|
||||
|
||||
Run:
|
||||
```
|
||||
cargo test -p ailang-check install_int_to_str_signature
|
||||
```
|
||||
|
||||
Expected: **PASS** (this test exercises only the checker and the
|
||||
`synth_in_builtins_env` helper, not codegen lowering — the
|
||||
signature install is sufficient for this test to pass even without
|
||||
Task 2's lowering work).
|
||||
|
||||
- [ ] **Step 5: Confirm `install_float_to_str_signature` still
|
||||
passes**
|
||||
|
||||
Run:
|
||||
```
|
||||
cargo test -p ailang-check install_float_to_str_signature
|
||||
```
|
||||
|
||||
Expected: **PASS**. Sanity check that the int-side insertion did
|
||||
not perturb the float-side entry.
|
||||
|
||||
---
|
||||
|
||||
## Task 2 — Codegen lowering + IR-header preamble + IR-shape tests + drop.rs comment
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/ailang-codegen/src/lib.rs:486-535` (IR-header
|
||||
preamble — add two new `declare` lines)
|
||||
- Modify: `crates/ailang-codegen/src/lib.rs:1890-1903`
|
||||
(`Emitter::lower_app` — replace `float_to_str` body, add
|
||||
`int_to_str` arm)
|
||||
- Modify: `crates/ailang-codegen/src/lib.rs` test module (append two
|
||||
IR-shape tests)
|
||||
- Modify: `crates/ailang-codegen/src/drop.rs:374-381` (refresh stale
|
||||
comment)
|
||||
|
||||
- [ ] **Step 1: Extend the IR-header preamble**
|
||||
|
||||
Locate the existing `declare` block at
|
||||
`crates/ailang-codegen/src/lib.rs:486-535`. After the line that
|
||||
declares `@ail_str_compare` (currently line 529) and before
|
||||
`declare i64 @llvm.fptosi.sat.i64.f64(double)` at line 535 — i.e.
|
||||
in the "language runtime externs" stretch — append the two new
|
||||
declarations unconditionally:
|
||||
|
||||
```rust
|
||||
out.push_str("declare ptr @ailang_int_to_str(i64)\n");
|
||||
out.push_str("declare ptr @ailang_float_to_str(double)\n");
|
||||
```
|
||||
|
||||
These sit alongside the unconditional `@ail_str_eq` and
|
||||
`@ail_str_compare` declarations (added per iter 23.2/23.3 — the
|
||||
surrounding comments justify the unconditional placement on the
|
||||
ground that the str.c TU is unconditionally linked).
|
||||
|
||||
- [ ] **Step 2: Write the two new IR-shape tests (RED)**
|
||||
|
||||
Append to the test module at the bottom of
|
||||
`crates/ailang-codegen/src/lib.rs`, after the existing
|
||||
`compare_str_calls_ail_str_compare_with_bytes_pointer` test
|
||||
(currently around line 4019). Use the same fixture-construction
|
||||
style as the existing IR-shape tests:
|
||||
|
||||
```rust
|
||||
/// Iter hs.4: a `Literal::App` calling `int_to_str` lowers to
|
||||
/// `call ptr @ailang_int_to_str(i64 %a)` — pins the new builtin's
|
||||
/// lowering shape.
|
||||
#[test]
|
||||
fn int_to_str_lowers_to_ailang_int_to_str_call() {
|
||||
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 {
|
||||
fn_: Box::new(Term::Var { name: "int_to_str".into() }),
|
||||
args: vec![Term::Lit { lit: Literal::Int { value: 42 } }],
|
||||
}],
|
||||
tail: false,
|
||||
},
|
||||
suppress: vec![],
|
||||
doc: None,
|
||||
})],
|
||||
};
|
||||
let ir = emit_ir(&m).unwrap();
|
||||
assert!(
|
||||
ir.contains("call ptr @ailang_int_to_str(i64 "),
|
||||
"expected lowering of int_to_str to call @ailang_int_to_str; ir was:\n{ir}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Iter hs.4: `float_to_str` no longer raises CodegenError::Internal —
|
||||
/// it lowers to `call ptr @ailang_float_to_str(double %a)`.
|
||||
#[test]
|
||||
fn float_to_str_no_longer_errors_internal() {
|
||||
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 {
|
||||
fn_: Box::new(Term::Var { name: "float_to_str".into() }),
|
||||
args: vec![Term::Lit { lit: Literal::Float { bits: (3.5_f64).to_bits() } }],
|
||||
}],
|
||||
tail: false,
|
||||
},
|
||||
suppress: vec![],
|
||||
doc: None,
|
||||
})],
|
||||
};
|
||||
let ir = emit_ir(&m).unwrap();
|
||||
assert!(
|
||||
ir.contains("call ptr @ailang_float_to_str(double "),
|
||||
"expected lowering of float_to_str to call @ailang_float_to_str; ir was:\n{ir}"
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
The `Term::Var { name: "int_to_str".into() }` carries the builtin
|
||||
through the type-replay so `synth_arg_type` resolves it via the
|
||||
synth.rs arm Task 1 Step 3 installed.
|
||||
|
||||
- [ ] **Step 3: Run the new tests and confirm RED**
|
||||
|
||||
Run:
|
||||
```
|
||||
cargo test -p ailang-codegen --lib int_to_str_lowers float_to_str_no_longer
|
||||
```
|
||||
|
||||
Expected: both tests **FAIL** — `int_to_str` is not yet lowered
|
||||
(reaches the unknown-builtin fallback or panics on `synth_arg_type`
|
||||
even with the synth.rs arm because lower_app's chain has no
|
||||
matching `if name == "int_to_str"` arm); `float_to_str` still
|
||||
returns `CodegenError::Internal` so `emit_ir` returns `Err` and
|
||||
the assertion fails when the body unwraps.
|
||||
|
||||
- [ ] **Step 4: Replace the `float_to_str` arm in `Emitter::lower_app`**
|
||||
|
||||
Locate the existing arm at
|
||||
`crates/ailang-codegen/src/lib.rs:1890-1903`. The current body
|
||||
returns
|
||||
`Err(CodegenError::Internal("`float_to_str` codegen lowering is not yet implemented (requires dynamic Str allocation in the runtime)".into()))`.
|
||||
Replace with a call emission. Reuse the same shape as
|
||||
`int_to_float` at `crates/ailang-codegen/src/lib.rs:1859-1867` for
|
||||
the SSA + call pattern. After the edit, the `if name == "float_to_str"`
|
||||
arm reads:
|
||||
|
||||
```rust
|
||||
if name == "float_to_str" {
|
||||
let (a_v, _a_ty) = self.lower_term_to_value(&args[0])?;
|
||||
let dst = self.fresh_ssa();
|
||||
self.body.push_str(&format!(
|
||||
" {dst} = call ptr @ailang_float_to_str(double {a_v})\n"
|
||||
));
|
||||
return Ok((dst, "ptr".to_string()));
|
||||
}
|
||||
```
|
||||
|
||||
The exact helper names (`lower_term_to_value`, `fresh_ssa`,
|
||||
`self.body.push_str`) and the `Result<(String, String)>` shape must
|
||||
match the surrounding arms — confirm against the `int_to_float` arm
|
||||
verbatim.
|
||||
|
||||
- [ ] **Step 5: Add the new `int_to_str` arm**
|
||||
|
||||
Adjacent to the `float_to_str` arm (placement immediately above or
|
||||
below; co-location keeps the two heap-Str formatters discoverable
|
||||
together), insert:
|
||||
|
||||
```rust
|
||||
if name == "int_to_str" {
|
||||
let (a_v, _a_ty) = self.lower_term_to_value(&args[0])?;
|
||||
let dst = self.fresh_ssa();
|
||||
self.body.push_str(&format!(
|
||||
" {dst} = call ptr @ailang_int_to_str(i64 {a_v})\n"
|
||||
));
|
||||
return Ok((dst, "ptr".to_string()));
|
||||
}
|
||||
```
|
||||
|
||||
Diff vs. `float_to_str`: only the name (`"int_to_str"`), the
|
||||
runtime symbol (`@ailang_int_to_str`), and the LLVM argument type
|
||||
(`i64` instead of `double`) differ.
|
||||
|
||||
- [ ] **Step 6: Run the IR-shape tests and confirm GREEN**
|
||||
|
||||
Run:
|
||||
```
|
||||
cargo test -p ailang-codegen --lib int_to_str_lowers float_to_str_no_longer
|
||||
```
|
||||
|
||||
Expected: both tests **PASS**.
|
||||
|
||||
- [ ] **Step 7: Update the stale `Str` arm comment in
|
||||
`field_drop_call`**
|
||||
|
||||
Locate the comment at
|
||||
`crates/ailang-codegen/src/drop.rs:374-381`. The current comment
|
||||
reads:
|
||||
|
||||
```rust
|
||||
// Built-in pointer-typed cons: Str. No drop fn —
|
||||
// shallow `ailang_rc_dec` is the right answer (Str
|
||||
// payloads are NUL-terminated bytes in static
|
||||
// memory; nothing to recurse into).
|
||||
```
|
||||
|
||||
Replace with the dual-realisation framing per spec §Components /
|
||||
lib.rs item 4:
|
||||
|
||||
```rust
|
||||
// Built-in pointer-typed cons: Str. No drop fn —
|
||||
// shallow `ailang_rc_dec` is the right answer.
|
||||
// Str has two realisations sharing the consumer
|
||||
// ABI (len at offset 0, bytes at offset 8):
|
||||
// - heap-Str: malloc'd slab with real rc_header
|
||||
// at `payload - 8`; rc_dec is the correct
|
||||
// refcount-and-free path.
|
||||
// - static-Str: packed-struct LLVM global
|
||||
// <{ i64, [N x i8] }> in .rodata, no rc_header
|
||||
// slot; rc_dec would read undefined bytes at
|
||||
// `payload - 8`. Codegen-level elision
|
||||
// (`emit_inlined_partial_drop` move-tracking
|
||||
// from iter 18d.3 + non-escape lowering from
|
||||
// iter 18b) keeps static-Str pointers out of
|
||||
// this call along every shipping execution
|
||||
// path. The codegen-level invariant is the
|
||||
// protection; no runtime guard backs it up.
|
||||
```
|
||||
|
||||
The dispatched symbol (`ailang_rc_dec`) is unchanged — only the
|
||||
comment evolves.
|
||||
|
||||
- [ ] **Step 8: Run the full ailang-codegen test suite for
|
||||
collateral**
|
||||
|
||||
Run:
|
||||
```
|
||||
cargo test -p ailang-codegen
|
||||
```
|
||||
|
||||
Expected: every test **PASSes**. Tests not touched by Tasks 1-2
|
||||
(drop walks, ADT lowering, match, eq, compare, the four +8 GEP
|
||||
tests, the two static_str_ shape tests from hs.2) must remain
|
||||
green.
|
||||
|
||||
---
|
||||
|
||||
## Task 3 — Hoist runtime/rc.c to unconditional link
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/ail/src/main.rs:2271-2293` (insertion point for
|
||||
the hoisted rc.c block, adjacent to the existing str.c block),
|
||||
`:2327-2356` (the `AllocStrategy::Rc` arm where rc.c lives today)
|
||||
|
||||
- [ ] **Step 1: Read the current str.c link block as the template**
|
||||
|
||||
Open `crates/ail/src/main.rs:2271-2293`. Confirm the str.c block:
|
||||
locates `runtime/str.c` via `locate_str_runtime()`, runs
|
||||
`clang -c -O2 -o <tmpdir>/str.o <str_path>`, propagates errors via
|
||||
`.context("compiling runtime/str.c")?`, exits-on-status-failure with
|
||||
a stderr message, then appends `clang.arg(&str_obj)`. This is the
|
||||
template — the hoisted rc.c block will mirror it.
|
||||
|
||||
- [ ] **Step 2: Hoist the rc.c compile-and-link block**
|
||||
|
||||
Locate the `AllocStrategy::Rc` arm at `crates/ail/src/main.rs:2327-2356`.
|
||||
The relevant lines are the ones that:
|
||||
1. Call `locate_rc_runtime()` to resolve `runtime/rc.c`,
|
||||
2. Run `clang -c -O2 -o <tmpdir>/rc.o <rc_path>`,
|
||||
3. Exit on status failure with an "rc.c" message,
|
||||
4. Append `clang.arg(&rc_obj)`.
|
||||
|
||||
Cut these and paste them next to the str.c block at `:2293` (after
|
||||
the `clang.arg(&str_obj)` line, before the `match alloc { … }`
|
||||
block at `:2294`). After the move, the rc.c block sits in the
|
||||
unconditional path; the `AllocStrategy::Rc` arm shrinks (its
|
||||
remaining body is whatever Rc-specific work survives — likely just
|
||||
the IR-side `--alloc=rc` flag handling and the lack of any `-lgc`
|
||||
or `bump.o` integration).
|
||||
|
||||
A sample diff sketch (exact text depends on the surrounding helper
|
||||
names — confirm against the actual `:2271-2293` block during
|
||||
editing):
|
||||
|
||||
```rust
|
||||
// Iter 23.2: compile and link `runtime/str.c` unconditionally —
|
||||
// … (existing comment block)
|
||||
let str_path = locate_str_runtime()?;
|
||||
// … (existing str.c compile + link block)
|
||||
clang.arg(&str_obj);
|
||||
|
||||
// Iter hs.4: compile and link `runtime/rc.c` unconditionally —
|
||||
// hoisted out of the AllocStrategy::Rc arm. `runtime/str.c`'s
|
||||
// `ailang_int_to_str` / `ailang_float_to_str` call
|
||||
// `ailang_rc_alloc` defined in rc.c; the weak extern declaration
|
||||
// in str.c (`runtime/str.c:35`) resolves to the strong definition
|
||||
// here. Under --alloc=gc / --alloc=bump the alloc strategy still
|
||||
// governs ADT allocation (GC_malloc / bump_malloc); rc.c provides
|
||||
// the heap-Str primitives in parallel.
|
||||
let rc_path = locate_rc_runtime()?;
|
||||
let rc_obj = tmpdir.path().join("rc.o");
|
||||
let cstatus = std::process::Command::new("clang")
|
||||
.arg("-c").arg("-O2")
|
||||
.arg("-o").arg(&rc_obj)
|
||||
.arg(&rc_path)
|
||||
.status()
|
||||
.context("compiling runtime/rc.c")?;
|
||||
if !cstatus.success() {
|
||||
eprintln!("clang failed compiling rc.c (status {})", cstatus);
|
||||
std::process::exit(cstatus.code().unwrap_or(1));
|
||||
}
|
||||
clang.arg(&rc_obj);
|
||||
```
|
||||
|
||||
(Exact identifiers — `tmpdir`, the error path — must match the
|
||||
local style. Treat the snippet as a structural sketch, not a
|
||||
verbatim insert.)
|
||||
|
||||
In the `match alloc { AllocStrategy::Rc => { … } }` arm at
|
||||
`:2327-2356`, remove the four lines that performed the rc.c
|
||||
compile-and-link. If the arm's only remaining work was those four
|
||||
lines, the arm body becomes empty (`AllocStrategy::Rc => {}`).
|
||||
Retain the arm in the match — `--alloc=rc` is still a valid CLI
|
||||
flag that drives codegen's `@ailang_rc_alloc`-vs-`@GC_malloc`
|
||||
selection; removing the match-arm would break that.
|
||||
|
||||
- [ ] **Step 3: Sanity test under all three alloc strategies**
|
||||
|
||||
Run a minimal smoke build under each alloc strategy:
|
||||
|
||||
```
|
||||
cargo run -p ail --release -- build examples/hello.ail.json --alloc=gc -o /tmp/hello_gc
|
||||
/tmp/hello_gc
|
||||
cargo run -p ail --release -- build examples/hello.ail.json --alloc=bump -o /tmp/hello_bump
|
||||
/tmp/hello_bump
|
||||
cargo run -p ail --release -- build examples/hello.ail.json --alloc=rc -o /tmp/hello_rc
|
||||
/tmp/hello_rc
|
||||
```
|
||||
|
||||
Expected: all three binaries print `Hello, AILang.` (or whatever
|
||||
hello.ail's expected output is — verify via the existing snapshot
|
||||
`crates/ail/tests/snapshots/hello.ll` corresponds to expected
|
||||
stdout). All three link successfully (rc.o now unconditionally
|
||||
present in every link).
|
||||
|
||||
- [ ] **Step 4: Full workspace test sweep**
|
||||
|
||||
Run:
|
||||
```
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
Expected: every test **PASSes**. This is the gate that rc.o being
|
||||
unconditionally linked does not break gc / bump path tests — in
|
||||
particular the e2e tests under `crates/ail/tests/e2e.rs` that use
|
||||
the default-`gc` `build_and_run` helper.
|
||||
|
||||
---
|
||||
|
||||
## Task 4 — Stdout + RC-stats E2E tests with fixtures
|
||||
|
||||
**Files:**
|
||||
- Create: `examples/int_to_str_smoke.ail.json`
|
||||
- Create: `examples/float_to_str_smoke.ail.json`
|
||||
- Create: `examples/int_to_str_drop_rc.ail.json`
|
||||
- Create: `examples/str_field_in_adt_heap.ail.json`
|
||||
- Modify: `crates/ail/tests/e2e.rs` (append four new tests)
|
||||
- Create (if missing): `crates/ail/tests/expected/int_to_str_smoke.txt`,
|
||||
`crates/ail/tests/expected/float_to_str_smoke.txt` (or assert
|
||||
inline; pick whichever pattern the existing `_smoke` tests use —
|
||||
inspect `crates/ail/tests/e2e.rs` near line 90 for prior style)
|
||||
|
||||
- [ ] **Step 1: Write the int_to_str smoke fixture**
|
||||
|
||||
Create `examples/int_to_str_smoke.ail.json` with four print
|
||||
statements covering 0, 42, -1, i64::MAX in sequence:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": "ailang/v0",
|
||||
"name": "int_to_str_smoke",
|
||||
"imports": [],
|
||||
"defs": [
|
||||
{
|
||||
"kind": "fn",
|
||||
"name": "main",
|
||||
"type": {
|
||||
"k": "fn", "params": [], "ret": { "k": "con", "name": "Unit" },
|
||||
"effects": ["IO"]
|
||||
},
|
||||
"params": [],
|
||||
"body": {
|
||||
"t": "do", "op": "io/print_str",
|
||||
"args": [{
|
||||
"t": "app",
|
||||
"fn": { "t": "var", "name": "int_to_str" },
|
||||
"args": [{ "t": "lit", "lit": { "kind": "int", "value": 42 } }]
|
||||
}]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Ship a single-value smoke fixture covering `int_to_str(42)` only.
|
||||
The spec also names edge cases `0`, `-1`, `i64::MAX`, `i64::MIN`;
|
||||
these are deferred to a follow-up tidy iter if hs.4's regression
|
||||
sweep does not surface a need for them. The single-value smoke is
|
||||
sufficient to demonstrate the codegen + runtime path is wired
|
||||
end-to-end.
|
||||
|
||||
- [ ] **Step 2: Write the float_to_str smoke fixture**
|
||||
|
||||
Create `examples/float_to_str_smoke.ail.json` analogously, printing
|
||||
`float_to_str(3.5)`. The literal float is encoded as `{ "kind":
|
||||
"float", "value": <ieee_bits_as_u64> }` matching the
|
||||
`Literal::Float { bits }` shape (cf. existing fixtures with float
|
||||
literals — `grep -ln '"kind": "float"' examples/` for examples).
|
||||
For 3.5, bits = `(3.5_f64).to_bits() = 0x400C000000000000`.
|
||||
|
||||
- [ ] **Step 3: Write the int_to_str drop-RC fixture**
|
||||
|
||||
Create `examples/int_to_str_drop_rc.ail.json`. The fixture
|
||||
allocates N heap-Str values via `int_to_str` and drops them without
|
||||
aliasing. A minimal shape: a single top-level `do io/print_str(int_to_str(42))`
|
||||
allocates one slab; the program exits and the slab is freed
|
||||
on scope close. The test asserts `allocs == frees && live == 0` via
|
||||
`build_and_run_with_rc_stats`. If a single-allocation form is not
|
||||
sufficient to demonstrate the discipline (e.g. it does not exercise
|
||||
the full alloc + free path), the fixture instead does
|
||||
`let s = int_to_str(42) in io/print_str(s)` so the binding's drop
|
||||
fires explicitly. Pick whichever shape produces a non-trivial
|
||||
`allocs >= 1, frees >= 1` count.
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": "ailang/v0",
|
||||
"name": "int_to_str_drop_rc",
|
||||
"imports": [],
|
||||
"defs": [
|
||||
{
|
||||
"kind": "fn",
|
||||
"name": "main",
|
||||
"type": {
|
||||
"k": "fn", "params": [], "ret": { "k": "con", "name": "Unit" },
|
||||
"effects": ["IO"]
|
||||
},
|
||||
"params": [],
|
||||
"body": {
|
||||
"t": "let",
|
||||
"name": "s",
|
||||
"ty": { "k": "con", "name": "Str" },
|
||||
"value": {
|
||||
"t": "app",
|
||||
"fn": { "t": "var", "name": "int_to_str" },
|
||||
"args": [{ "t": "lit", "lit": { "kind": "int", "value": 42 } }]
|
||||
},
|
||||
"body": {
|
||||
"t": "do", "op": "io/print_str",
|
||||
"args": [{ "t": "var", "name": "s" }]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
If the `Term::Let` JSON shape differs from this sketch, locate
|
||||
the canonical form via `grep -ln '"t": "let"' examples/` and use
|
||||
the matched fixture's shape as a template. The semantic shape (a
|
||||
binding allocating once, used once, dropped at scope close) is
|
||||
fixed; the JSON syntactic shape follows whatever the existing
|
||||
schema-form requires.
|
||||
|
||||
- [ ] **Step 4: Write the Str-in-ADT drop fixture**
|
||||
|
||||
Create `examples/str_field_in_adt_heap.ail.json`. Defines a single
|
||||
ADT `type Boxed = | Box(Str)`, constructs `Box(int_to_str(42))`,
|
||||
binds it, prints something derived from it (so the constructor and
|
||||
extractor are exercised), and lets the binding drop at scope close.
|
||||
Consult an existing ADT-with-Str-field fixture for the construct +
|
||||
pattern-match shape (likely under `examples/rc_box_drop.ail.json`
|
||||
or a sibling — `grep -ln 'type.*Box.*Str\|Box(.*Str' examples/`).
|
||||
|
||||
The test asserts `allocs == frees && live == 0` via
|
||||
`build_and_run_with_rc_stats` — the ADT cell *and* the heap-Str
|
||||
inside it must both round-trip through the RC discipline.
|
||||
|
||||
- [ ] **Step 5: Add the four new E2E tests**
|
||||
|
||||
Append to the end of `crates/ail/tests/e2e.rs`:
|
||||
|
||||
```rust
|
||||
/// Iter hs.4: `int_to_str(42)` prints "42\n" through the standard
|
||||
/// `do io/print_str(...)` path. Smoke pin for the heap-Str builtin.
|
||||
#[test]
|
||||
fn int_to_str_smoke() {
|
||||
let out = build_and_run("int_to_str_smoke.ail.json");
|
||||
assert_eq!(out, "42\n");
|
||||
}
|
||||
|
||||
/// Iter hs.4: `float_to_str(3.5)` prints a libc-%g-conformant
|
||||
/// rendering of 3.5. Exact bytes pinned against the dev target's
|
||||
/// printf output ("3.5\n").
|
||||
#[test]
|
||||
fn float_to_str_smoke() {
|
||||
let out = build_and_run("float_to_str_smoke.ail.json");
|
||||
assert_eq!(out, "3.5\n");
|
||||
}
|
||||
|
||||
/// Iter hs.4: `int_to_str` participates in RC discipline — every
|
||||
/// allocation is matched by a drop at scope close.
|
||||
#[test]
|
||||
fn int_to_str_drop_balances_rc_stats() {
|
||||
let (stdout, allocs, frees, live) =
|
||||
build_and_run_with_rc_stats("int_to_str_drop_rc.ail.json");
|
||||
assert_eq!(stdout, "42\n");
|
||||
assert!(allocs >= 1, "expected at least one heap-Str allocation; got allocs={allocs}");
|
||||
assert_eq!(allocs, frees, "every heap-Str alloc must be freed; allocs={allocs}, frees={frees}");
|
||||
assert_eq!(live, 0, "no leaked heap-Str slabs allowed; live={live}");
|
||||
}
|
||||
|
||||
/// Iter hs.4: an ADT carrying a heap-Str field drops both the ADT
|
||||
/// cell and the Str payload through the RC walk. Pins the
|
||||
/// field_drop_call dispatch for heap-Str (drop.rs:372 routes Str →
|
||||
/// ailang_rc_dec; the heap-Str rc_header is real).
|
||||
#[test]
|
||||
fn str_field_in_adt_drops_heap_str_correctly() {
|
||||
let (_stdout, allocs, frees, live) =
|
||||
build_and_run_with_rc_stats("str_field_in_adt_heap.ail.json");
|
||||
assert!(allocs >= 2, "expected ADT cell + heap-Str slab; got allocs={allocs}");
|
||||
assert_eq!(allocs, frees, "every RC slab must be freed; allocs={allocs}, frees={frees}");
|
||||
assert_eq!(live, 0, "no leaked RC slabs allowed; live={live}");
|
||||
}
|
||||
```
|
||||
|
||||
The `build_and_run_with_rc_stats` helper's return tuple shape is
|
||||
`(String, u64, u64, i64)` per recon at `crates/ail/tests/e2e.rs:2093`;
|
||||
the `live` field can be signed (alloc < free case).
|
||||
|
||||
- [ ] **Step 6: Run the four new E2E tests**
|
||||
|
||||
Run:
|
||||
```
|
||||
cargo test -p ail --test e2e int_to_str_smoke float_to_str_smoke int_to_str_drop_balances_rc_stats str_field_in_adt_drops_heap_str_correctly
|
||||
```
|
||||
|
||||
Expected: all four tests **PASS**. If `float_to_str_smoke` fails
|
||||
because libc's `%g` on the dev target renders `3.5` differently
|
||||
(e.g. as `3.50000` or with a trailing `0`), inspect the actual
|
||||
stdout and pin against the observed bytes — add a comment that the
|
||||
exact rendering is libc-target-dependent.
|
||||
|
||||
- [ ] **Step 7: Cross-language stdout regression sweep**
|
||||
|
||||
Run:
|
||||
```
|
||||
bench/cross_lang.py
|
||||
```
|
||||
|
||||
Expected: every entry **green**. The new fixtures themselves do
|
||||
not have a C reference counterpart (they exercise an AILang-only
|
||||
codegen path); their absence from the C-reference corpus is
|
||||
expected. The gate verifies that existing C-paired fixtures still
|
||||
produce byte-identical stdout.
|
||||
|
||||
- [ ] **Step 8: Workspace-compile regression sweep**
|
||||
|
||||
Run:
|
||||
```
|
||||
bench/compile_check.py
|
||||
```
|
||||
|
||||
Expected: every entry **green**. The new fixtures themselves
|
||||
should now compile end-to-end (they will appear in
|
||||
`bench/compile_check.py`'s scanner).
|
||||
|
||||
- [ ] **Step 9: Full workspace test sweep**
|
||||
|
||||
Run:
|
||||
```
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
Expected: every test **PASSes**.
|
||||
|
||||
- [ ] **Step 10: Latency baselines check**
|
||||
|
||||
Run:
|
||||
```
|
||||
bench/check.py
|
||||
```
|
||||
|
||||
Expected: green within the known `latency.explicit_at_rc.*`
|
||||
nondeterminism tolerance documented in recent audits.
|
||||
|
||||
---
|
||||
|
||||
## Acceptance for this iteration
|
||||
|
||||
- `int_to_str : (Int) -> Str` installed in the checker; codegen
|
||||
lowers `int_to_str` to `call ptr @ailang_int_to_str(i64 %a)`.
|
||||
- `float_to_str` no longer raises `CodegenError::Internal`; lowers
|
||||
to `call ptr @ailang_float_to_str(double %a)`.
|
||||
- IR-header preamble unconditionally declares both runtime symbols.
|
||||
- `runtime/rc.c` is compiled and linked into every binary
|
||||
regardless of `--alloc` strategy.
|
||||
- Two new IR-shape tests pass; six existing IR-shape tests stay
|
||||
green.
|
||||
- Four new E2E tests pass; existing E2E suite stays green.
|
||||
- `crates/ailang-codegen/src/drop.rs:374-381` comment reflects the
|
||||
dual-realisation heap-Str + static-Str reality.
|
||||
- `cargo test --workspace`, `bench/cross_lang.py`,
|
||||
`bench/compile_check.py`, `bench/check.py` all green.
|
||||
- The `__attribute__((weak))` on `ailang_rc_alloc` in
|
||||
`runtime/str.c` is now a no-op (strong definition wins) but
|
||||
retained intentionally per the hs.3 journal Concerns note.
|
||||
- Hs.5 remains as the final iter: DESIGN.md anchors + WhatsNew +
|
||||
audit-close.
|
||||
Reference in New Issue
Block a user