aver-lang 0.18.0

VM and transpiler for Aver, a statically-typed language designed for AI-assisted development
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
//! Expression / statement → wasm-gc instructions.
//!
//! Lowering rules (snapshot — full coverage of the bench scenarios and
//! the game suite):
//!
//! - `Literal(Int|Float|Bool|Unit|Str)` → primitive consts / array.new_data.
//! - `Resolved { slot }` → `local.get slot`. Resolver assigns slots in
//!   declaration order (params first, bindings next), which matches
//!   wasm's local-indexing convention 1:1, so no remapping needed.
//! - `BinOp(Add|Sub|Mul|Div, Int, Int)` → `i64.{add,sub,mul,div_s}`,
//!   F64 variants for Float.
//! - `BinOp(<comparison>, ..)` → typed `eq/ne/lt/gt/le/ge` per operand
//!   wasm type.
//! - `FnCall(Ident, args)` → `call $idx` after pushing args left-to-right.
//! - `Stmt::Binding` → emit value, then `local.set slot` where slot
//!   is the next-available counter (same convention as resolver).
//!
//! Step 3 of the 0.16 typed-ABI refactor deleted the parallel ad-hoc
//! inference layer that lived in `body/infer.rs` + `body/slots.rs`.
//! Every emit/slot site now reads `Spanned::ty()` directly via the
//! readers in `body/infer.rs` (`aver_type_of`, `aver_type_str_of`,
//! `wasm_type_of`). Missing `ty()` panics — that's a typecheck or
//! synthesizer bug, not a recoverable codegen condition.

use std::collections::{HashMap, HashSet};

use wasm_encoder::{Function, Instruction, ValType};

use super::WasmGcError;
use super::types::TypeRegistry;

use crate::ast::{FnBody, FnDef, Stmt};
use crate::ir::CallLowerCtx;

mod builtins;
mod builtins_wasip2;
mod emit;
pub(super) mod eq_helpers;
pub(super) mod hash_helpers;
mod infer;
mod slots;

use emit::emit_expr;
use infer::aver_type_str_of;
use slots::{SlotTable, count_value_params};

/// Maps fn name → wasm fn index + return type. Built once per module.
pub(super) struct FnMap {
    pub(super) by_name: std::collections::HashMap<String, FnEntry>,
    /// Dotted builtin name → wasm fn index. Populated by
    /// `module::emit_module` from the `BuiltinRegistry` so call
    /// sites can `call $builtin_idx` for `String.fromInt` etc.
    pub(super) builtins: std::collections::HashMap<String, u32>,
    /// Dotted effect name → wasm fn index (host import). Populated
    /// from `EffectRegistry`. Imports occupy fn idx 0..K so these
    /// indices are always small.
    pub(super) effects: std::collections::HashMap<String, u32>,
    /// Per-instantiation `Map<K, V>` helpers (empty / set / get / len).
    /// Key is the canonical `Map<K,V>` Aver string. Body emit looks
    /// the canonical up by inferring the type of the map argument.
    pub(super) map_helpers: std::collections::HashMap<String, super::maps::MapKVHelpers>,
    /// Per-`List<T>` helpers (len / reverse). Key = canonical Aver
    /// string `List<T>`.
    pub(super) list_ops: std::collections::HashMap<String, super::lists::ListOps>,
    /// Per-`List<T>` `Vector.fromList` helper (paired with the
    /// matching `Vector<T>` registered in the type registry).
    pub(super) vfl_ops: std::collections::HashMap<String, super::lists::VectorFromListOps>,
    /// Per-`Tuple<A,B>` `List.zip` helper. Registered when the
    /// surface code calls `List.zip` and all three lists exist
    /// in the registry.
    pub(super) zip_ops: std::collections::HashMap<String, u32>,
    /// Singleton `String.split` / `String.join` helpers (T=String).
    /// Registered when the surface code calls either.
    pub(super) string_split_ops: Option<super::lists::StringSplitOps>,
    /// Per-(record/sum) `__eq_<TypeName>` helpers used by `BinOp::Eq`
    /// / `BinOp::Neq` over nominal types. Key = bare type name.
    pub(super) eq_helpers: std::collections::HashMap<String, u32>,
}

impl FnMap {
    /// Lookup a `List<T>` helper triple, falling back to the bare-name
    /// form when the canonical carries a module qualifier (`List<Mod.X>`
    /// → `List<X>`). Multi-module flatten can leave inner type strings
    /// in either form depending on whether the call site originated in
    /// the entry or a dep module.
    pub(super) fn list_ops_lookup(&self, canonical: &str) -> Option<&super::lists::ListOps> {
        if let Some(o) = self.list_ops.get(canonical) {
            return Some(o);
        }
        let bare = super::types::strip_inner_dotted_prefixes(canonical);
        if bare != canonical {
            self.list_ops.get(&bare)
        } else {
            None
        }
    }

    pub(super) fn map_helpers_lookup(&self, canonical: &str) -> Option<&super::maps::MapKVHelpers> {
        if let Some(o) = self.map_helpers.get(canonical) {
            return Some(o);
        }
        let bare = super::types::strip_inner_dotted_prefixes(canonical);
        if bare != canonical {
            self.map_helpers.get(&bare)
        } else {
            None
        }
    }

    pub(super) fn vfl_ops_lookup(
        &self,
        canonical: &str,
    ) -> Option<&super::lists::VectorFromListOps> {
        if let Some(o) = self.vfl_ops.get(canonical) {
            return Some(o);
        }
        let bare = super::types::strip_inner_dotted_prefixes(canonical);
        if bare != canonical {
            self.vfl_ops.get(&bare)
        } else {
            None
        }
    }

    pub(super) fn zip_ops_lookup(&self, canonical: &str) -> Option<u32> {
        if let Some(&o) = self.zip_ops.get(canonical) {
            return Some(o);
        }
        let bare = super::types::strip_inner_dotted_prefixes(canonical);
        if bare != canonical {
            self.zip_ops.get(&bare).copied()
        } else {
            None
        }
    }
}

pub(super) struct FnEntry {
    pub(super) wasm_idx: u32,
    /// Kept for symmetry with the other backends' fn-table entries —
    /// wasm-gc emit reads return type from `fd.return_type` directly
    /// today, but the slot stays so the JSON-shaped `FnMap` consumed
    /// by `--explain-passes` retains the field.
    #[allow(dead_code)]
    pub(super) return_type: String,
}

/// Lower the body of `fd` into the supplied wasm `Function` builder.
/// Returns the list of *extra* locals (beyond params) needed for the
/// fn signature; caller passes these to `Function::new`.
///
/// `self_wasm_idx` is the current fn's own wasm index — used for
/// emitting `return_call $self` on `Expr::TailCall` to the same fn.
/// Mutual-TCO across SCC members goes through a `return_call_indirect`
/// table; that wiring lives in module.rs.
#[allow(clippy::too_many_arguments)]
pub(super) fn emit_fn_body(
    func: &mut Function,
    fd: &FnDef,
    fn_map: &FnMap,
    self_wasm_idx: u32,
    registry: &TypeRegistry,
    effect_idx_lookup: &HashMap<String, u32>,
    caller_fn_collector: &std::cell::RefCell<CallerFnCollector>,
    wasip2_lowering: Option<&Wasip2Lowering>,
) -> Result<Vec<ValType>, WasmGcError> {
    let slots = SlotTable::build_for_fn(fd, registry, fn_map)?;
    let FnBody::Block(stmts) = fd.body.as_ref();
    let last_idx = stmts.len().saturating_sub(1);

    // Precollect every `let`-bound name so `CallLowerCtx::is_local_value`
    // can recognise locals without a parallel type table — the wasm-gc
    // backend's IR shape recognition (`classify_leaf_op` /
    // `classify_call_plan`) only needs the name, not the type.
    let mut binding_names: HashSet<String> = HashSet::new();
    fn collect_names(stmts: &[Stmt], out: &mut HashSet<String>) {
        for s in stmts {
            if let Stmt::Binding(name, _, _) = s {
                out.insert(name.clone());
            }
        }
    }
    collect_names(stmts, &mut binding_names);

    let ctx = EmitCtx {
        fn_map,
        self_wasm_idx,
        self_fn_name: fd.name.as_str(),
        return_type: fd.return_type.as_str(),
        registry,
        resolution: fd.resolution.as_ref(),
        params: &fd.params,
        binding_names: &binding_names,
        effect_idx_lookup,
        caller_fn_collector,
        wasip2_lowering,
    };

    for (i, stmt) in stmts.iter().enumerate() {
        let is_last = i == last_idx;
        match stmt {
            Stmt::Binding(name, _annot, expr) => {
                emit_expr(func, expr, &slots, &ctx)?;
                let produces_value = aver_type_str_of(expr).trim() != "Unit";
                if name == "_" {
                    // `_ = expr` — sequence-only binding. Drop the
                    // value (if any) and move on; the resolver
                    // doesn't allocate a slot for `_`.
                    if produces_value {
                        func.instruction(&Instruction::Drop);
                    }
                    continue;
                }
                let slot = ctx
                    .self_local_slot(name)
                    .ok_or(WasmGcError::Validation(format!(
                        "binding `{name}` has no resolver slot"
                    )))?;
                // Unit expressions push nothing — there's nothing to
                // stash, and the slot itself is an i32 placeholder
                // (kept around to preserve resolver slot indices).
                if produces_value && (slot as usize) < slots.by_slot.len() {
                    func.instruction(&Instruction::LocalSet(slot));
                }
            }
            Stmt::Expr(spanned) => {
                emit_expr(func, spanned, &slots, &ctx)?;
                // Whether the expression leaves a value on the stack
                // is decided structurally (typecheck stamps `Unit` on
                // pure-effect expressions, every other shape pushes a
                // value). We avoid `aver_to_wasm` here so that a type
                // carrying an unresolved `Type::Var` (e.g. for a
                // generic call site the registry can't lower without
                // context) doesn't poison the trailing-drop decision.
                let produces_value = aver_type_str_of(spanned).trim() != "Unit";
                if !is_last && produces_value {
                    func.instruction(&Instruction::Drop);
                }
                if is_last {
                    if fd.return_type.trim() == "Unit" && produces_value {
                        func.instruction(&Instruction::Drop);
                    } else if fd.return_type.trim() != "Unit" && !produces_value {
                        return Err(WasmGcError::Validation(format!(
                            "fn `{}` returns {} but trailing expression yields no value",
                            fd.name, fd.return_type
                        )));
                    }
                }
            }
        }
    }
    func.instruction(&Instruction::End);

    Ok(slots.extra_locals(count_value_params(&fd.params)))
}

/// Lazy-populated registry of caller_fn names actually emitted by the
/// codegen. Replaces the AST walker `fn_body_emits_effect_call` from
/// 0.16.2 — every site that calls `emit_caller_fn_idx` registers the
/// fn name here on demand, so the wasm output's caller-fn name table
/// contains exactly the fns whose bodies emit a caller_fn slot. Zero
/// false positives, zero rozjazdy walker↔codegen.
#[derive(Default)]
pub(super) struct CallerFnCollector {
    /// Aver fn name → idx in the exported caller-fn table (0..N).
    pub(super) idx_by_name: HashMap<String, u32>,
    /// Insertion order — the host walks `__caller_fn_name(0..N)` and
    /// the i-th entry must match `names[i]`.
    pub(super) names: Vec<String>,
}

impl CallerFnCollector {
    /// Get-or-insert a name, returning the i32 idx the codegen emits
    /// at the call site. New names land at the end of `names`.
    pub(super) fn register(&mut self, name: &str) -> u32 {
        if let Some(&i) = self.idx_by_name.get(name) {
            return i;
        }
        let i = self.names.len() as u32;
        self.names.push(name.to_string());
        self.idx_by_name.insert(name.to_string(), i);
        i
    }
}

/// Per-fn lowering context — read-only state every emit fn needs.
pub(super) struct EmitCtx<'a> {
    pub(super) fn_map: &'a FnMap,
    pub(super) self_wasm_idx: u32,
    pub(super) self_fn_name: &'a str,
    pub(super) return_type: &'a str,
    pub(super) registry: &'a TypeRegistry,
    /// Resolver's local-name → slot map for the current fn. `None`
    /// when the fn was emitted without `resolution` populated (the
    /// pipeline always populates it for production paths; tests may
    /// pre-resolve manually).
    pub(super) resolution: Option<&'a crate::ast::FnResolution>,
    /// Param name → declared aver type. Used by `CallLowerCtx` for
    /// local-name recognition; the typed-AST refactor (Step 3) made
    /// the *type* portion redundant for emit, but the param list is
    /// still the source of truth for "is this name a param?".
    pub(super) params: &'a [(String, String)],
    /// Set of `let`-bound local names (no type information attached —
    /// types come from `Spanned::ty()` at the use site). Powers
    /// `CallLowerCtx::is_local_value` for the shared IR-level shape
    /// recognition.
    pub(super) binding_names: &'a HashSet<String>,
    /// Effect canonical name → wasm fn idx. Body emit reaches into
    /// this for the structural-scope markers around `?!` /
    /// `!` (`__record_enter_group`, `__record_set_branch`,
    /// `__record_exit_group`). Empty when the program declares no
    /// independent product anywhere — discovery only registers the
    /// three host imports if it sees one.
    pub(super) effect_idx_lookup: &'a HashMap<String, u32>,
    /// Lazy collector for caller_fn names. `emit_caller_fn_idx`
    /// registers the current fn name here on each effect call site;
    /// the eventual `__caller_fn_name(idx)` body and data segments
    /// are emitted from `names` after every fn body has been
    /// produced.
    pub(super) caller_fn_collector: &'a std::cell::RefCell<CallerFnCollector>,
    /// `Some(...)` when the wasm-gc emitter was invoked under
    /// `TargetMode::Wasip2` AND the program registers at least one
    /// of `Console.print` / `Console.error` / `Console.warn`. Carries
    /// the fn / global / helper indices the call-site lowering needs
    /// to emit canonical-ABI imports of `wasi:cli/{stdout,stderr}`
    /// and `wasi:io/streams.[method]output-stream.blocking-write-and-
    /// flush`. Phase 1.2b1.5.
    pub(super) wasip2_lowering: Option<&'a Wasip2Lowering>,
}

/// Concrete fn / global / helper indices the wasip2 call-site
/// lowering reads. Built once per module in `module.rs` after the
/// import section / globals section / bridge fn indices have been
/// allocated, then handed to every `emit_fn_body` call as a
/// borrowed reference. Each `Option<...>` field is populated when
/// at least one effect that needs it is registered.
pub(super) struct Wasip2Lowering {
    // ── Phase 1.2b1.5: Console.{print, error, warn} ─────────────
    /// `wasi:cli/stdout.get-stdout` imported wasm fn idx.
    pub(super) get_stdout_fn_idx: Option<u32>,
    /// `wasi:cli/stderr.get-stderr` imported wasm fn idx.
    pub(super) get_stderr_fn_idx: Option<u32>,
    /// `wasi:io/streams.[method]output-stream.blocking-write-and-flush`
    /// imported wasm fn idx. `Some(...)` when any `Console.*` is
    /// registered; `None` otherwise.
    pub(super) blocking_write_fn_idx: Option<u32>,
    /// Mutable i32 global caching the stdout `output-stream` resource
    /// handle. Initial value `-1` ("not yet resolved").
    pub(super) stdout_handle_global: Option<u32>,
    /// Same shape for stderr.
    pub(super) stderr_handle_global: Option<u32>,
    /// `__rt_string_to_lm` helper wasm fn idx. Reused from the
    /// existing JS-bridge helper machinery — on wasip2 it stays
    /// internal (not exported) and copies utf-8 bytes from the
    /// Aver `(ref null $string)` to LM[0..len]. `Some(...)` when
    /// any string-marshalling effect (i.e., `Console.*`) is wired.
    pub(super) str_to_lm_fn_idx: Option<u32>,

    // ── Phase 1.4: Time.unixMs / Random.{int, float} ───────────
    /// `wasi:clocks/wall-clock.now` imported wasm fn idx.
    /// `Some(...)` when `Time.unixMs` is registered.
    pub(super) clocks_now_fn_idx: Option<u32>,
    /// `wasi:random/random.get-random-u64` imported wasm fn idx.
    /// `Some(...)` when at least one of `Random.{int, float}` is
    /// registered (the same import drives both).
    pub(super) random_u64_fn_idx: Option<u32>,

    // ── Phase 1.3.2: Args.get + shared canonical-ABI decoders ──
    /// `wasi:cli/environment.get-arguments` imported wasm fn idx.
    /// `Some(...)` when `Args.get` is registered.
    pub(super) get_arguments_fn_idx: Option<u32>,
    /// `cabi_realloc` exported wasm fn idx — the bump-allocator
    /// helper from Phase 1.3.1. Required by every list-returning
    /// canonical-ABI import (host calls it to allocate the list
    /// payload + per-element bytes in guest memory). `Some(...)`
    /// when any wasip2 import is registered.
    pub(super) cabi_realloc_fn_idx: Option<u32>,
    /// `__rt_canonical_decode_list_string(retptr) -> List<String>`
    /// helper wasm fn idx. Walks the canonical-ABI lowered form
    /// `(list_ptr i32, list_len i32)` at retptr → Aver
    /// `List<String>` (cons cells + GC `(array i8)` strings).
    /// Shared across every list-returning effect: `Args.get`
    /// today, `Disk.listDir` (Phase 1.5), more later. Emitted
    /// once per module when any consumer is registered.
    pub(super) decode_list_string_fn_idx: Option<u32>,

    // ── Phase 1.3.3: Env.get ──────────────────────────────────
    /// `wasi:cli/environment.get-environment` imported wasm fn idx.
    /// `Some(...)` when `Env.get` is registered.
    pub(super) get_environment_fn_idx: Option<u32>,
    /// `__rt_canonical_env_lookup(retptr, key_ptr, key_len) ->
    /// String` helper wasm fn idx. Walks the canonical-ABI
    /// lowered `list<tuple<string, string>>` at retptr,
    /// linear-searches for an entry whose key matches the
    /// caller-supplied LM byte range, and materialises the
    /// matching value as a fresh GC `(array i8)`. Returns an
    /// empty `(array i8)` when no match — preserves Aver
    /// `Env.get(name) -> String` semantics (no Option/Result).
    pub(super) env_get_lookup_fn_idx: Option<u32>,
    /// Phase 1.4b — `__rt_format_iso8601(secs i64, nanos i32) ->
    /// ref null $string` helper wasm fn idx. Pure-compute helper
    /// that turns the datetime returned by
    /// `wasi:clocks/wall-clock.now` into Aver's RFC3339-like
    /// `Time.now() -> String` value. Allocated whenever wasip2 +
    /// the clocks slot are wired (i.e. unconditionally with
    /// Time.unixMs); `wasm-opt -Oz` strips this when no source
    /// `Time.now` reaches the helper.
    pub(super) fmt_iso8601_fn_idx: Option<u32>,
    /// Phase 1.3.4 — `__rt_console_read_line() -> ref null
    /// $result_string_string` helper wasm fn idx. Loops 1-byte
    /// `wasi:io/streams.[method]input-stream.blocking-read` calls
    /// against the cached `wasi:cli/stdin.get-stdin` handle until
    /// `\n` or EOF, accumulates into a `cabi_realloc`-owned
    /// buffer, and returns `Result.Ok(line)` (or `Result.Err("EOF")`
    /// when the first read produces zero bytes).
    pub(super) console_read_line_fn_idx: Option<u32>,
    /// Phase 1.4c — `__rt_time_sleep(ms i64) -> ()` helper wasm
    /// fn idx. Wraps `wasi:clocks/monotonic-clock.subscribe-
    /// duration` + `wasi:io/poll.poll` + `[resource-drop]pollable`
    /// — the pollable is per-call, allocated and dropped inside
    /// the helper, so it never leaks to source-level Aver.
    pub(super) time_sleep_fn_idx: Option<u32>,
    /// Phase 1.5.1 — `__rt_disk_exists(path: ref string) -> i32`
    /// helper wasm fn idx. Lazy-fetches the first preopen
    /// descriptor (cached in the disk_preopen_handle global),
    /// marshals the path through `__rt_string_to_lm`, calls
    /// `wasi:filesystem/types.[method]descriptor.stat-at` and
    /// returns the boolean result tag (1 = exists, 0 = not).
    pub(super) disk_exists_fn_idx: Option<u32>,
    /// Phase 1.5.2 — `__rt_disk_read_text(path: ref string) ->
    /// ref null $result_string_string` helper wasm fn idx. Owns
    /// open-at + read-via-stream + blocking-read loop + per-call
    /// resource drops. Returns `Result.Ok(content)` on success,
    /// `Result.Err("…")` on any wasi failure (open / stream /
    /// read).
    pub(super) disk_read_text_fn_idx: Option<u32>,
    /// Phase 1.5.3 — `__rt_disk_write_text(path: ref string,
    /// content: ref string) -> ref null $result_unit_string`
    /// helper wasm fn idx. Owns open-at(create+truncate) +
    /// write-via-stream + blocking-write-and-flush + per-call
    /// resource drops.
    pub(super) disk_write_text_fn_idx: Option<u32>,
    /// Phase 1.5.5 — `__rt_disk_append_text(path, content) ->
    /// Result<Unit, String>`. Same body emitter as
    /// `__rt_disk_write_text` flipped to append mode (no
    /// truncate, append-via-stream instead of write-via-stream).
    pub(super) disk_append_text_fn_idx: Option<u32>,
    /// Phase 1.5.4 — single-call wasi ops sharing
    /// `emit_disk_simple_path_op` (preopen + path marshalling +
    /// 4-byte retptr + tag check). One fn each per Aver effect
    /// since the wasi op fn idx + Err message differ.
    pub(super) disk_delete_fn_idx: Option<u32>,
    pub(super) disk_delete_dir_fn_idx: Option<u32>,
    pub(super) disk_make_dir_fn_idx: Option<u32>,
    /// Phase 1.5.6 — `__rt_disk_list_dir(path: ref string) ->
    /// ref null $result_list_string_string` helper wasm fn idx.
    /// Owns open-at(directory) + read-directory + entry-iteration
    /// loop + drops; cons-builds a `List<String>` of entry names.
    pub(super) disk_list_dir_fn_idx: Option<u32>,
}

impl<'a> EmitCtx<'a> {
    /// Look up a local-name → wasm slot. Resolver slots are 1:1 with
    /// wasm local indices.
    pub(super) fn self_local_slot(&self, name: &str) -> Option<u32> {
        self.resolution
            .as_ref()
            .and_then(|r| r.local_slots.get(name).copied())
            .map(|s| s as u32)
    }

    /// Whether `slot` is flagged as alias-prone by `ir::alias`. Backends
    /// that want to skip clone-on-write must check this first; default
    /// `false` for slots outside the table (resolution missing,
    /// scratch slots) is the safe-but-fast answer (no aliasing
    /// suspicion → in-place is sound). The IR pass always runs in real
    /// builds, so an absent table only happens in test paths that
    /// pre-resolve manually.
    pub(super) fn is_aliased_slot(&self, slot: u16) -> bool {
        self.resolution
            .and_then(|r| r.aliased_slots.get(slot as usize).copied())
            .unwrap_or(false)
    }

    /// True when `arg` is a `Resolved` slot whose binding is dead
    /// after this expression (`last_use=true`) AND not flagged
    /// alias-prone — the underlying engine array / struct is
    /// uniquely owned, so the alias-aware fast path is sound.
    /// Used by `Vector.set` (skips `array.copy` of the backing array)
    /// and `Map.set` (picks the `set_in_place` helper). Anonymous
    /// expressions land here as a transient stack value with no
    /// binding to alias against, which counts as uniquely owned.
    pub(super) fn arg_uniquely_owned(&self, arg: &crate::ast::Spanned<crate::ast::Expr>) -> bool {
        match &arg.node {
            crate::ast::Expr::Resolved { slot, last_use, .. } => {
                last_use.0 && !self.is_aliased_slot(*slot)
            }
            _ => true,
        }
    }
}

/// `CallLowerCtx` impl so the shared IR-level shape recognition
/// (`classify_leaf_op`, `classify_call_plan`) can be reused here
/// instead of each backend re-implementing the same patterns. Wasm-gc
/// is single-module today so module resolution returns None; the
/// other two predicates fall out of the registry + binding/param
/// tables we already maintain.
impl<'a> CallLowerCtx for EmitCtx<'a> {
    fn is_local_value(&self, name: &str) -> bool {
        self.params.iter().any(|(n, _)| n == name) || self.binding_names.contains(name)
    }

    fn is_user_type(&self, name: &str) -> bool {
        self.registry.records.contains_key(name)
            || self.registry.variants.contains_key(name)
            || self
                .registry
                .variants
                .values()
                .flat_map(|vs| vs.iter())
                .any(|info| info.parent == name)
    }

    fn resolve_module_call<'b>(&self, _dotted: &'b str) -> Option<(&'b str, &'b str)> {
        None
    }
}