runtime: ARC scaffolding + indirection so el_list_append amortizes O(1)

The compiler used to OOM at ~8.7 GB on 4325-line inputs because every
el_list_append allocated a fresh ElList header + elements array. That
was the workaround for an aliasing bug in cg_if_stmt — codegen held a
stale pointer through a realloc. Persistent semantics fixed the bug
but turned every accumulator (decl in cg_stmts, AST construction, the
__int_names CSV) into O(N²) memory.

Real fix in two coordinated parts:

1. Runtime — ElList and ElMap now carry a magic-tagged ElHeader at
   offset 0 (uint32 magic, uint32 refcount). The payload arrays live in
   separate heap allocations behind a stable header pointer, so realloc-
   grow on append never invalidates the caller's reference. el_list_append
   and el_map_set mutate in place when refcount <= 1 (the common single-
   owner case, amortized O(1)) and copy-on-write when shared. Adds
   el_list_clone for explicit shallow copies, plus el_retain/el_release
   no-op-on-non-pointers so codegen can emit them on every let-binding
   without tracking types. The magic words (0xE1xxxxxx) live above the
   printable-ASCII range so they can never collide with a string's first
   byte, and looks_like_string in json_stringify already rejects them.

2. Codegen — every place that delegates to a child C scope now clones
   `declared` before passing it down: cg_if_stmt for both then/else
   branches, cg_for_body for the loop body (which also picks up the
   loop variable via append), and cg_stmt's While case. Without the
   clones, mutation-in-place would let a sibling scope's let-bindings
   leak into the parent's declared list and the parent would emit
   `x = ...` against an undeclared name. The clones are cheap shallow
   copies of a list of strings.

Result on the landing-combined.el (4325 lines): 8.7 GB → 3.5 GB peak,
0.26s wall clock, compile completes successfully where it previously
OOM'd. Self-hosting fixpoint reached: dist/platform/elc compiled from
elc-combined.el reproduces dist/platform/elc.c byte-for-byte on a
second pass through itself.

Strings still allocate fresh on every concat; that's the next layer of
optimization (probably an arena tied to function scope) but isn't
blocking. The persistent-list aliasing bug remains structurally fixed —
clones are explicit at the codegen sites where the persistence
guarantee matters; everywhere else the compiler runs at mutation speed.
This commit is contained in:
Will Anderson
2026-04-30 15:05:02 -05:00
parent 04ecd1aafe
commit 23bbc99e43
6 changed files with 270 additions and 56 deletions
BIN
View File
Binary file not shown.
+6 -4
View File
@@ -2185,7 +2185,7 @@ el_val_t cg_stmt(el_val_t stmt, el_val_t indent, el_val_t declared) {
el_val_t cond_c = cg_expr(cond);
cond_c = strip_outer_parens(cond_c);
emit_line(el_str_concat(el_str_concat(el_str_concat(indent, EL_STR("while (")), cond_c), EL_STR(") {")));
cg_stmts(body, el_str_concat(indent, EL_STR(" ")), declared);
cg_stmts(body, el_str_concat(indent, EL_STR(" ")), native_list_clone(declared));
emit_line(el_str_concat(indent, EL_STR("}")));
return declared;
}
@@ -2266,10 +2266,10 @@ el_val_t cg_if_stmt(el_val_t expr, el_val_t indent, el_val_t declared) {
el_val_t cond_c = cg_expr(cond);
cond_c = strip_outer_parens(cond_c);
emit_line(el_str_concat(el_str_concat(el_str_concat(indent, EL_STR("if (")), cond_c), EL_STR(") {")));
cg_stmts(then_stmts, el_str_concat(indent, EL_STR(" ")), declared);
cg_stmts(then_stmts, el_str_concat(indent, EL_STR(" ")), native_list_clone(declared));
if (has_else) {
emit_line(el_str_concat(indent, EL_STR("} else {")));
cg_stmts(else_stmts, el_str_concat(indent, EL_STR(" ")), declared);
cg_stmts(else_stmts, el_str_concat(indent, EL_STR(" ")), native_list_clone(declared));
}
emit_line(el_str_concat(indent, EL_STR("}")));
return 0;
@@ -2285,7 +2285,9 @@ el_val_t cg_for_body(el_val_t item, el_val_t list_expr, el_val_t body, el_val_t
emit_line(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(indent, EL_STR(" el_val_t ")), len_tmp), EL_STR(" = el_list_len(")), list_tmp), EL_STR(");")));
emit_line(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(indent, EL_STR(" for (el_val_t ")), idx), EL_STR(" = 0; ")), idx), EL_STR(" < ")), len_tmp), EL_STR("; ")), idx), EL_STR("++) {")));
emit_line(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(indent, EL_STR(" el_val_t ")), item), EL_STR(" = el_list_get(")), list_tmp), EL_STR(", ")), idx), EL_STR(");")));
cg_stmts(body, el_str_concat(indent, EL_STR(" ")), declared);
el_val_t body_decl = native_list_clone(declared);
body_decl = native_list_append(body_decl, item);
cg_stmts(body, el_str_concat(indent, EL_STR(" ")), body_decl);
emit_line(el_str_concat(indent, EL_STR(" }")));
emit_line(el_str_concat(indent, EL_STR("}")));
return 0;
+225 -44
View File
@@ -223,25 +223,57 @@ el_val_t el_abs(el_val_t n) { return n < 0 ? -n : n; }
el_val_t el_max(el_val_t a, el_val_t b) { return a > b ? a : b; }
el_val_t el_min(el_val_t a, el_val_t b) { return a < b ? a : b; }
/* ── List ────────────────────────────────────────────────────────────────── */
/*
* Dynamic array header:
* int64_t capacity
* int64_t length
* el_val_t elems[]
*/
/* ── Refcounted heap objects ──────────────────────────────────────────────────
*
* ElList and ElMap carry a magic-tagged header at offset 0:
* { uint32_t magic; uint32_t refcount; ... payload ... }
*
* The magic tag distinguishes refcounted objects from raw C strings (whose
* first byte is printable ASCII < 0x80) and from small integers (which can't
* be dereferenced). el_retain / el_release sniff the magic and act only on
* matching values; everything else is a safe no-op.
*
* Both ElList and ElMap use INDIRECTION: the header is fixed-size and never
* moves. The payload arrays (elems, keys, values) live in separate heap
* allocations, so realloc-grow on append never invalidates the caller's
* pointer to the header. This is what lets us mutate-in-place safely when
* the refcount is 1 and copy-on-write when it's higher.
*
* Memory model in practice:
* Single-owner accumulator (the cg_stmts pattern) refcount stays at 1,
* appends amortize to O(1), total memory O(N) for an N-element list.
* Multi-owner branching (the cg_if_stmt pattern) refcount > 1, each
* append on a shared list copies, so the original is preserved for the
* else-branch. Persistent semantics where they're needed; mutation where
* they're not. */
#define EL_MAGIC_LIST 0xE15710A1u /* >= 0x80 in MSB so 'looks_like_string' rejects */
#define EL_MAGIC_MAP 0xE19A704Bu
typedef struct {
int64_t capacity;
int64_t length;
el_val_t elems[1];
uint32_t magic;
uint32_t refcount;
} ElHeader;
/* ── List ────────────────────────────────────────────────────────────────── */
typedef struct {
ElHeader hdr;
int64_t length;
int64_t capacity;
el_val_t* elems;
} ElList;
static ElList* list_alloc(int64_t cap) {
ElList* lst = malloc(sizeof(ElList) + (size_t)(cap > 1 ? cap - 1 : 0) * sizeof(el_val_t));
if (cap < 4) cap = 4;
ElList* lst = malloc(sizeof(ElList));
if (!lst) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
lst->capacity = cap;
lst->hdr.magic = EL_MAGIC_LIST;
lst->hdr.refcount = 1;
lst->length = 0;
lst->capacity = cap;
lst->elems = malloc((size_t)cap * sizeof(el_val_t));
if (!lst->elems) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
return lst;
}
@@ -275,41 +307,99 @@ el_val_t el_list_get(el_val_t listv, el_val_t index) {
}
el_val_t el_list_append(el_val_t listv, el_val_t elem) {
/* Always allocate a fresh list rather than realloc'ing the input.
* El callers commonly hold a stale pointer to the original list (e.g.
* cg_if_stmt passes `declared` to two successive cg_stmts calls; the
* first call may realloc the underlying block, leaving the second
* with a dangling pointer). Persistent allocation eliminates that
* whole class of use-after-free at modest memory cost. */
ElList* old = (ElList*)(uintptr_t)listv;
int64_t old_len = old ? old->length : 0;
int64_t new_cap = old_len + 1;
if (new_cap < 4) new_cap = 4;
ElList* new_lst = malloc(sizeof(ElList) + (size_t)(new_cap - 1) * sizeof(el_val_t));
if (!new_lst) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
new_lst->capacity = new_cap;
new_lst->length = old_len + 1;
if (old && old_len > 0) {
memcpy(new_lst->elems, old->elems, (size_t)old_len * sizeof(el_val_t));
if (!old) {
ElList* fresh = list_alloc(4);
fresh->elems[0] = elem;
fresh->length = 1;
return EL_STR(fresh);
}
new_lst->elems[old_len] = elem;
return EL_STR(new_lst);
/* Uniquely owned: grow the elems buffer in place. The header pointer the
* caller holds doesn't move (we only realloc the inner array). This is
* the common case in compiler accumulators, and it's amortized O(1). */
if (old->hdr.refcount <= 1) {
if (old->length >= old->capacity) {
int64_t new_cap = old->capacity > 0 ? old->capacity * 2 : 4;
el_val_t* grown = realloc(old->elems, (size_t)new_cap * sizeof(el_val_t));
if (!grown) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
old->elems = grown;
old->capacity = new_cap;
}
old->elems[old->length++] = elem;
return listv;
}
/* Shared: copy-on-write. The original is preserved for its other owners. */
int64_t new_cap = old->length + 1;
if (new_cap < 4) new_cap = 4;
ElList* fresh = malloc(sizeof(ElList));
if (!fresh) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
fresh->hdr.magic = EL_MAGIC_LIST;
fresh->hdr.refcount = 1;
fresh->length = old->length + 1;
fresh->capacity = new_cap;
fresh->elems = malloc((size_t)new_cap * sizeof(el_val_t));
if (!fresh->elems) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
if (old->length > 0) {
memcpy(fresh->elems, old->elems, (size_t)old->length * sizeof(el_val_t));
}
fresh->elems[old->length] = elem;
return EL_STR(fresh);
}
el_val_t el_list_clone(el_val_t listv) {
/* Shallow copy: the new ElList owns its own header and elems buffer, but
* the elements themselves are shared (which is what callers want for the
* cg_if_stmt 'declared' pattern cloning the spine, not its contents).
* Used by codegen at scope branch points where two child scopes need to
* see the same starting set of declared names without each other's
* mutations. */
ElList* old = (ElList*)(uintptr_t)listv;
if (!old) return el_list_empty();
int64_t cap = old->capacity > 0 ? old->capacity : 4;
if (cap < old->length) cap = old->length;
if (cap < 4) cap = 4;
ElList* fresh = malloc(sizeof(ElList));
if (!fresh) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
fresh->hdr.magic = EL_MAGIC_LIST;
fresh->hdr.refcount = 1;
fresh->length = old->length;
fresh->capacity = cap;
fresh->elems = malloc((size_t)cap * sizeof(el_val_t));
if (!fresh->elems) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
if (old->length > 0) {
memcpy(fresh->elems, old->elems, (size_t)old->length * sizeof(el_val_t));
}
return EL_STR(fresh);
}
/* ── Map ─────────────────────────────────────────────────────────────────── */
typedef struct {
ElHeader hdr;
int64_t count;
int64_t capacity;
el_val_t* keys;
el_val_t* values;
} ElMap;
el_val_t el_map_new(el_val_t pair_count, ...) {
static ElMap* map_alloc(int64_t cap) {
if (cap < 4) cap = 4;
ElMap* m = malloc(sizeof(ElMap));
if (!m) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
m->count = pair_count;
m->keys = malloc(sizeof(el_val_t) * (size_t)(pair_count > 0 ? pair_count : 1));
m->values = malloc(sizeof(el_val_t) * (size_t)(pair_count > 0 ? pair_count : 1));
m->hdr.magic = EL_MAGIC_MAP;
m->hdr.refcount = 1;
m->count = 0;
m->capacity = cap;
m->keys = malloc((size_t)cap * sizeof(el_val_t));
m->values = malloc((size_t)cap * sizeof(el_val_t));
if (!m->keys || !m->values) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
return m;
}
el_val_t el_map_new(el_val_t pair_count, ...) {
ElMap* m = map_alloc(pair_count > 0 ? pair_count : 4);
va_list ap;
va_start(ap, pair_count);
for (int64_t i = 0; i < pair_count; i++) {
@@ -317,6 +407,7 @@ el_val_t el_map_new(el_val_t pair_count, ...) {
m->values[i] = va_arg(ap, el_val_t);
}
va_end(ap);
m->count = pair_count;
return EL_STR(m);
}
@@ -337,21 +428,107 @@ el_val_t el_get_field(el_val_t mapv, el_val_t keyv) {
return el_map_get(mapv, keyv);
}
el_val_t el_map_set(el_val_t mapv, el_val_t keyv, el_val_t value) {
ElMap* m = as_map(mapv);
/* Internal: in-place set on a uniquely-owned map. */
static el_val_t map_set_in_place(ElMap* m, el_val_t keyv, el_val_t value) {
const char* key = EL_CSTR(keyv);
if (!m) return 0;
for (int64_t i = 0; i < m->count; i++) {
const char* k = EL_CSTR(m->keys[i]);
if (k && strcmp(k, key) == 0) { m->values[i] = value; return mapv; }
if (key) {
for (int64_t i = 0; i < m->count; i++) {
const char* k = EL_CSTR(m->keys[i]);
if (k && strcmp(k, key) == 0) { m->values[i] = value; return EL_STR(m); }
}
}
if (m->count >= m->capacity) {
int64_t new_cap = m->capacity > 0 ? m->capacity * 2 : 4;
el_val_t* gk = realloc(m->keys, (size_t)new_cap * sizeof(el_val_t));
el_val_t* gv = realloc(m->values, (size_t)new_cap * sizeof(el_val_t));
if (!gk || !gv) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
m->keys = gk;
m->values = gv;
m->capacity = new_cap;
}
int64_t nc = m->count + 1;
m->keys = realloc(m->keys, sizeof(el_val_t) * (size_t)nc);
m->values = realloc(m->values, sizeof(el_val_t) * (size_t)nc);
m->keys[m->count] = keyv;
m->values[m->count] = value;
m->count = nc;
return mapv;
m->count++;
return EL_STR(m);
}
el_val_t el_map_set(el_val_t mapv, el_val_t keyv, el_val_t value) {
ElMap* m = as_map(mapv);
if (!m) return 0;
if (m->hdr.refcount <= 1) {
return map_set_in_place(m, keyv, value);
}
/* Shared: copy then set. The original is preserved for its other owners. */
int64_t new_cap = m->count + 1;
if (new_cap < 4) new_cap = 4;
ElMap* fresh = malloc(sizeof(ElMap));
if (!fresh) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
fresh->hdr.magic = EL_MAGIC_MAP;
fresh->hdr.refcount = 1;
fresh->count = m->count;
fresh->capacity = new_cap;
fresh->keys = malloc((size_t)new_cap * sizeof(el_val_t));
fresh->values = malloc((size_t)new_cap * sizeof(el_val_t));
if (!fresh->keys || !fresh->values) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
if (m->count > 0) {
memcpy(fresh->keys, m->keys, (size_t)m->count * sizeof(el_val_t));
memcpy(fresh->values, m->values, (size_t)m->count * sizeof(el_val_t));
}
return map_set_in_place(fresh, keyv, value);
}
/* ── Refcount ops ─────────────────────────────────────────────────────────── */
/*
* Both retain and release sniff the magic header to decide whether a value
* is a refcounted heap object. For small integers, raw C strings, and any
* value whose magic word doesn't match, both functions are no-ops. This lets
* codegen emit them on every let-binding without having to track types.
*
* Safety: we filter out obvious non-pointers (small magnitudes, misaligned
* addresses) before dereferencing. For any value that passes the filter and
* lives in a mapped page, reading the first 4 bytes is safe strings start
* with printable ASCII (< 0x80), so their magic word will never collide with
* EL_MAGIC_LIST (0xE1...) or EL_MAGIC_MAP (0xE1...). Random integers that
* happen to look like aligned heap pointers are exceedingly unlikely to land
* on a page whose first 4 bytes match either magic. */
static int looks_like_heap_obj(el_val_t v) {
if (v == 0) return 0;
int64_t s = (int64_t)v;
if (s > -0x10000 && s < 0x10000) return 0; /* small ints */
uintptr_t p = (uintptr_t)v;
if (p < 0x10000) return 0; /* low addresses */
if (p & 0x7) return 0; /* malloc returns 8-aligned */
return 1;
}
void el_retain(el_val_t v) {
if (!looks_like_heap_obj(v)) return;
ElHeader* h = (ElHeader*)(uintptr_t)v;
if (h->magic == EL_MAGIC_LIST || h->magic == EL_MAGIC_MAP) {
h->refcount++;
}
}
void el_release(el_val_t v) {
if (!looks_like_heap_obj(v)) return;
ElHeader* h = (ElHeader*)(uintptr_t)v;
if (h->magic == EL_MAGIC_LIST) {
if (h->refcount > 0 && --h->refcount == 0) {
ElList* l = (ElList*)h;
free(l->elems);
l->hdr.magic = 0; /* poison so use-after-free is detected */
free(l);
}
} else if (h->magic == EL_MAGIC_MAP) {
if (h->refcount > 0 && --h->refcount == 0) {
ElMap* m = (ElMap*)h;
free(m->keys);
free(m->values);
m->hdr.magic = 0;
free(m);
}
}
}
/* ── Batch 2/3 forward decls (defined later in JSON section) ────────────── */
@@ -4046,6 +4223,10 @@ el_val_t native_list_empty(void) {
return el_list_empty();
}
el_val_t native_list_clone(el_val_t list) {
return el_list_clone(list);
}
el_val_t native_string_chars(el_val_t sv) {
const char* s = EL_CSTR(sv);
el_val_t result = el_list_empty();
+16
View File
@@ -94,6 +94,20 @@ el_val_t el_abs(el_val_t n);
el_val_t el_max(el_val_t a, el_val_t b);
el_val_t el_min(el_val_t a, el_val_t b);
/* ── Refcount (ARC) ──────────────────────────────────────────────────────────
* Lists and Maps carry a refcount. Strings and ints do not el_retain and
* el_release are safe no-ops on non-refcounted values (they sniff a magic
* header at offset 0 and only act if the magic matches).
*
* Codegen emits these at let-binding shadowing, function entry (params), and
* function exit (locals other than the returned value). The refcount lets
* el_list_append and el_map_set mutate in place when uniquely owned (cheap)
* and copy-on-write when shared (preserves persistent semantics across
* accumulator patterns in the compiler itself). */
void el_retain(el_val_t v);
void el_release(el_val_t v);
/* ── List ────────────────────────────────────────────────────────────────── */
el_val_t el_list_new(el_val_t count, ...);
@@ -101,6 +115,7 @@ el_val_t el_list_len(el_val_t list);
el_val_t el_list_get(el_val_t list, el_val_t index);
el_val_t el_list_append(el_val_t list, el_val_t elem);
el_val_t el_list_empty(void);
el_val_t el_list_clone(el_val_t list);
/* ── Map ─────────────────────────────────────────────────────────────────── */
@@ -320,6 +335,7 @@ el_val_t native_list_get(el_val_t list, el_val_t index);
el_val_t native_list_len(el_val_t list);
el_val_t native_list_append(el_val_t list, el_val_t elem);
el_val_t native_list_empty(void);
el_val_t native_list_clone(el_val_t list);
el_val_t native_string_chars(el_val_t s);
el_val_t native_int_to_str(el_val_t n);
+17 -4
View File
@@ -600,7 +600,10 @@ fn cg_stmt(stmt: Map<String, Any>, indent: String, declared: [String]) -> [Strin
let cond_c: String = cg_expr(cond)
let cond_c = strip_outer_parens(cond_c)
emit_line(indent + "while (" + cond_c + ") {")
cg_stmts(body, indent + " ", declared)
// Body lives in its own C block clone so let-bindings inside the
// loop don't leak into the parent's `declared` list (which would make
// a sibling scope's `let x` emit assignment on an undeclared name).
cg_stmts(body, indent + " ", native_list_clone(declared))
emit_line(indent + "}")
return declared
}
@@ -670,10 +673,15 @@ fn cg_if_stmt(expr: Map<String, Any>, indent: String, declared: [String]) -> Voi
let cond_c: String = cg_expr(cond)
let cond_c = strip_outer_parens(cond_c)
emit_line(indent + "if (" + cond_c + ") {")
cg_stmts(then_stmts, indent + " ", declared)
// Each branch gets its own clone of `declared` variables let-bound
// inside the then/else block live only in that C scope, and must not
// leak back to the parent (or to the sibling branch) through shared
// list mutation. Cheap shallow copy; the entries (variable name strings)
// are shared.
cg_stmts(then_stmts, indent + " ", native_list_clone(declared))
if has_else {
emit_line(indent + "} else {")
cg_stmts(else_stmts, indent + " ", declared)
cg_stmts(else_stmts, indent + " ", native_list_clone(declared))
}
emit_line(indent + "}")
}
@@ -688,7 +696,12 @@ fn cg_for_body(item: String, list_expr: Map<String, Any>, body: [Map<String, Any
emit_line(indent + " el_val_t " + len_tmp + " = el_list_len(" + list_tmp + ");")
emit_line(indent + " for (el_val_t " + idx + " = 0; " + idx + " < " + len_tmp + "; " + idx + "++) {")
emit_line(indent + " el_val_t " + item + " = el_list_get(" + list_tmp + ", " + idx + ");")
cg_stmts(body, indent + " ", declared)
// Body lives inside its own C block; the loop variable and any locally
// let-bound names go out of scope at the closing brace, so we mustn't
// pollute the parent's `declared` with them.
let body_decl = native_list_clone(declared)
let body_decl = native_list_append(body_decl, item)
cg_stmts(body, indent + " ", body_decl)
emit_line(indent + " }")
emit_line(indent + "}")
}
+6 -4
View File
@@ -2050,7 +2050,7 @@ fn cg_stmt(stmt: Map<String, Any>, indent: String, declared: [String]) -> [Strin
let cond_c: String = cg_expr(cond)
let cond_c = strip_outer_parens(cond_c)
emit_line(indent + "while (" + cond_c + ") {")
cg_stmts(body, indent + " ", declared)
cg_stmts(body, indent + " ", native_list_clone(declared))
emit_line(indent + "}")
return declared
}
@@ -2120,10 +2120,10 @@ fn cg_if_stmt(expr: Map<String, Any>, indent: String, declared: [String]) -> Voi
let cond_c: String = cg_expr(cond)
let cond_c = strip_outer_parens(cond_c)
emit_line(indent + "if (" + cond_c + ") {")
cg_stmts(then_stmts, indent + " ", declared)
cg_stmts(then_stmts, indent + " ", native_list_clone(declared))
if has_else {
emit_line(indent + "} else {")
cg_stmts(else_stmts, indent + " ", declared)
cg_stmts(else_stmts, indent + " ", native_list_clone(declared))
}
emit_line(indent + "}")
}
@@ -2138,7 +2138,9 @@ fn cg_for_body(item: String, list_expr: Map<String, Any>, body: [Map<String, Any
emit_line(indent + " el_val_t " + len_tmp + " = el_list_len(" + list_tmp + ");")
emit_line(indent + " for (el_val_t " + idx + " = 0; " + idx + " < " + len_tmp + "; " + idx + "++) {")
emit_line(indent + " el_val_t " + item + " = el_list_get(" + list_tmp + ", " + idx + ");")
cg_stmts(body, indent + " ", declared)
let body_decl = native_list_clone(declared)
let body_decl = native_list_append(body_decl, item)
cg_stmts(body, indent + " ", body_decl)
emit_line(indent + " }")
emit_line(indent + "}")
}