Quartz v5.25

WASM backend: .each() { it } / .filter() { it } on Vec emits invalid WASM

First seen: Apr 19, 2026, updating playground demos to the idiomatic implicit-it form prescribed by STYLE.md’s cheat sheet.

Symptom

Any trailing-block call on a Vec<T> that uses implicit it compiles to bytes, but wasmtime rejects the module at instantiation/compile with “unknown local N: local index out of bounds”.

Minimal repro:

def main(): Int
  scores = [95, 87, 92, 78, 100]
  scores.each() { puts("  #{it}") }
  return 0
end

Produces a 1600-byte .wasm file. wasmtime rejects it:

Error: failed to compile: wasm[0]::function[4]
  Caused by:
    WebAssembly translation error
    Invalid input WebAssembly code at offset 396:
      unknown local 13: local index out of bounds

Same failure with .filter() { it >= 90 }, .map() { it * 2 }. The LLVM and C backends handle these correctly — only WASM is affected.

What works and what doesn’t

FormWorks on WASM?
for s in scores / for x in items
scores.size(), scores.get(i)
.sum() / .fold() / .reduce() on Vec❌ (emits extern import to undefined env::sum etc.)
.each() { it ... } on Vec❌ (local-OOB)
.filter() { it ... } on Vec❌ (local-OOB)
.map() { it ... } on Vec❌ (local-OOB)
.each() { x -> ... } with explicit param on Vecuntested (likely same bug)
Closures declared and invoked manually (see closures demo)

So the WASM bug is scoped to Vec<T> higher-order methods that take a trailing block. Non-Vec closures work. for loops work. Explicit-index iteration works. sum/fold/reduce are missing from the WASM runtime entirely (separate issue — see below).

Likely cause

The trailing-block expansion for .each() { it ... } on Vec generates MIR that references a local by slot number that the WASM function-header’s local count doesn’t cover. local N where N is beyond the declared locals in the function’s entry preamble.

Cross-check against codegen.qz (LLVM) — it must be lowering the same MIR to register slots correctly. The WASM lowering in codegen_wasm.qz likely has a stale local-count that doesn’t get bumped when the trailing block introduces new slots.

.sum() on Vec<Int> on WASM emits a call to a symbol env::sum that the WASM runtime never defines. Same for .fold, .reduce, possibly more. These need to be added to wasm_runtime.qz (which is already 300 KiB, so there’s established room).

This is separate from the local-OOB bug above but shares a root cause: the WASM backend is behind on Vec-method support.

Workaround (used in collections.qz demo)

Rewrite idiomatic Vec-method chains as for loops with accumulators:

# Idiomatic but breaks on WASM:
total = scores.sum()
scores.each() { puts("  #{it}") }

# WASM-safe and still reasonable Quartz:
var total = 0
for s in scores
  total += s
end
for s in scores
  puts("  #{s}")
end

The user-facing cost: the playground’s collections.qz demo can’t show off .each() { it } / .filter() { it } / .map() { it } — the very idioms the STYLE.md cheat sheet now prescribes. Once this bug is fixed, the demo should be rewritten to match the prescribed idiom.

Repro

cat > /tmp/wasm-repro.qz <<'EOF'
def main(): Int
  v = [1, 2, 3]
  v.each() { puts("#{it}") }
  return 0
end
EOF

# LLVM backend (works):
./self-hosted/bin/quartz /tmp/wasm-repro.qz | llc -filetype=obj -o /tmp/r.o
clang /tmp/r.o -o /tmp/r -lm -lpthread && /tmp/r

# WASM backend (broken, requires --feature wasm compiler):
/tmp/quartz_wasm --backend wasm /tmp/wasm-repro.qz -o /tmp/r.wasm
wasmtime /tmp/r.wasm
# → Invalid input WebAssembly code ... unknown local N: local index out of bounds

Fix location (best guess)

self-hosted/backend/codegen_wasm.qz — the local-declaration emitter for functions that contain trailing-block expansions. Cross-reference mir_lower_iter.qz for how Vec-iteration blocks get lowered to MIR locals, then confirm codegen_wasm.qz reads the same local count.

Separately, wasm_runtime.qz needs .sum, .fold, .reduce, and probably other Vec reductions added.

Priority

P1 for the playground — every modern Quartz demo hits this the moment it reaches for .filter / .map / .each, which the cheat sheet now says is the default form. Ranked P1 instead of P0 only because the for loop workaround preserves the demos and the cheat sheet’s other idioms.