Skip to main content

relon_codegen_llvm/
state.rs

1//! Minimal runtime state for the LLVM AOT backend's buffer-protocol
2//! entries. **Phase B.**
3//!
4//! The buffer-protocol entry signature mirrors the cranelift-native
5//! backend's `EntryShape::BufferProtocol`:
6//!
7//! ```text
8//! fn run_main(state: *const SandboxState,
9//!             in_ptr: i32, in_len: i32,
10//!             out_ptr: i32, out_cap: i32,
11//!             caps: i64) -> i32;
12//! ```
13//!
14//! `LoadField` / `StoreField` ops resolve to absolute host addresses
15//! through the formula `arena_base + buf_ptr + offset`, where
16//! `arena_base` lives at a stable offset on the state. The LLVM
17//! emitter loads it through a `ptrtoint`/`inttoptr` round-trip.
18//!
19//! We **do not** reuse `relon_codegen_cranelift::SandboxState` here on
20//! purpose:
21//!
22//! - It would require pulling cranelift-native as a hard dependency of
23//!   the LLVM crate just to share an opaque struct layout. The LLVM
24//!   backend is meant to stand on its own.
25//! - The LLVM backend keeps its sandbox state local: arena bounds,
26//!   capability trap codes, host-fn dispatch, and the step-budget fuel
27//!   live in this C-layout `ArenaState` instead of depending on the
28//!   cranelift crate's `SandboxState`.
29//! - Keeping the layout local to this crate makes the offsets we
30//!   embed in emitted LLVM IR self-contained — if the cranelift
31//!   crate ever rearranges `SandboxState` it cannot accidentally
32//!   miscompile our IR.
33//!
34//! Phase C (when sandbox traps + closures land) is the right time to
35//! revisit the dep direction; for Phase B this stays self-contained.
36
37use std::cell::UnsafeCell;
38use std::collections::HashMap;
39use std::sync::Arc;
40
41use relon_eval_api::{NativeArgs, NativeFnCaps, RelonFunction, RuntimeError, Value};
42use relon_parser::TokenRange;
43
44/// Per-call arena state handed to the LLVM JIT-compiled entry. The
45/// emitter reads `arena_base` (at offset 0 on a 64-bit host) and
46/// `arena_len` (offset 8) to resolve every buffer-protocol load /
47/// store; everything past those two fields is reserved for Phase C
48/// (sandbox traps, deadline, closure table).
49///
50/// `#[repr(C)]` because the LLVM emitter hard-codes the field
51/// offsets through `inttoptr(arena_base_ptr + N)` style address
52/// arithmetic.
53///
54/// `UnsafeCell` on the live fields because the JIT thread mutates
55/// them through a raw pointer; Rust's borrow checker cannot see the
56/// emitted machine code. The per-call ownership model (one
57/// `ArenaState` per `run_main` dispatch) means no aliasing race
58/// can occur — the LLVM evaluator allocates a fresh state on the
59/// stack before each call.
60///
61/// ## Phase 0b: native-call dispatch
62///
63/// `host_fns` + `trap_code` mirror the cranelift backend's
64/// `SandboxState` so the LLVM JIT path can dispatch a source-lowered
65/// `Op::CallNative` through the host-fn registry the same way (see
66/// [`relon_llvm_call_native`]). `host_fns` is a raw pointer (not an
67/// `Arc` slot) because the registry is owned by the evaluator and
68/// outlives every per-call state; the emitter loads it by offset and
69/// hands it back to the helper verbatim. `0` (null) means "no
70/// registry installed" — a `CallNative` then records
71/// [`NativeTrap::HostFnMissing`] in `trap_code`.
72#[repr(C)]
73pub struct ArenaState {
74    /// Base pointer of the arena bytes the host owns. The emitted
75    /// LLVM IR reads this through `load i64, ptr %state` (offset 0),
76    /// then `inttoptr` to a byte pointer + i64-extended `buf_ptr` +
77    /// `field_offset`. The pointer is `usize`-wide so the cast
78    /// matches the host's pointer width.
79    pub arena_base: UnsafeCell<usize>,
80    /// Length of the arena in bytes. The LLVM emitter uses this for
81    /// arena-relative bounds guards before forming host pointers.
82    pub arena_len: UnsafeCell<u32>,
83    /// Phase E.1: tail cursor used by pointer-indirect StoreField
84    /// (`String` / `ListInt` / `ListFloat` / `ListBool`) to bump-
85    /// allocate records inside the output buffer's tail region.
86    /// Counts buffer-relative bytes from `out_ptr`. Reset to 0 at the
87    /// start of every dispatch.
88    pub tail_cursor: UnsafeCell<u32>,
89    /// Phase E.1: scratch bump cursor used by stdlib bodies (`concat`,
90    /// `substring`, ...) and `Op::StrConcatN` to allocate temporary
91    /// records inside the arena's scratch region. Counts bytes from
92    /// `scratch_base`. Reset to 0 per dispatch.
93    pub scratch_cursor: UnsafeCell<u32>,
94    /// Phase E.1: arena-relative byte offset at which the scratch
95    /// region starts (= `out_ptr + out_cap`). The bump path reads
96    /// `scratch_base + scratch_cursor` as the i32 pointer returned to
97    /// the stdlib body.
98    pub scratch_base: UnsafeCell<u32>,
99    /// Phase 0b: trap code recorded by [`relon_llvm_call_native`] on a
100    /// failed dispatch (host-fn missing / host-fn error / unsupported
101    /// arg shape). `0` = no trap. The `Op::CallNative` lowering loads
102    /// this right after the helper returns and routes a non-zero value
103    /// to an `llvm.trap`. Mirrors `SandboxState::trap_code`.
104    pub trap_code: UnsafeCell<u64>,
105    /// Phase 0b: raw pointer to the host-fn registry installed by the
106    /// evaluator before dispatch. Null when no registry was supplied.
107    /// The emitter loads this word and hands it to the helper; the
108    /// helper re-derives `&HostFnRegistry`. Lives outside the
109    /// `#[repr(C)]` codegen-visible prefix only through its offset —
110    /// it is a plain pointer-width field the JIT never dereferences
111    /// directly (only the helper does, on the Rust side).
112    pub host_fns: UnsafeCell<usize>,
113    /// Remaining loop/entry budget for the current dispatch. `0`
114    /// means "unlimited"; positive values are decremented by the LLVM
115    /// emitter at the entry prologue and loop headers; negative values
116    /// trap `ResourceExhausted`.
117    pub step_budget: UnsafeCell<i64>,
118}
119
120/// Byte offset of [`ArenaState::arena_base`] inside the `#[repr(C)]`
121/// layout. Used by the LLVM emitter to materialise the load.
122pub const ARENA_STATE_OFFSET_BASE: u32 = 0;
123
124/// Byte offset of [`ArenaState::arena_len`]. The LLVM emitter reads it
125/// before arena-relative host-pointer formation.
126pub const ARENA_STATE_OFFSET_LEN: u32 = std::mem::size_of::<usize>() as u32;
127
128/// Byte offset of [`ArenaState::tail_cursor`]. The pointer-indirect
129/// StoreField path loads and stores this u32 to bump-allocate the
130/// output buffer's tail region.
131pub const ARENA_STATE_OFFSET_TAIL_CURSOR: u32 = ARENA_STATE_OFFSET_LEN + 4;
132
133/// Byte offset of [`ArenaState::scratch_cursor`]. Loaded / stored by
134/// the `Op::AllocScratch` / `Op::AllocScratchDyn` lowering.
135pub const ARENA_STATE_OFFSET_SCRATCH_CURSOR: u32 = ARENA_STATE_OFFSET_TAIL_CURSOR + 4;
136
137/// Byte offset of [`ArenaState::scratch_base`]. Loaded by the scratch
138/// allocator to compute the arena-relative offset of a freshly-
139/// reserved scratch block (`scratch_base + scratch_cursor`).
140pub const ARENA_STATE_OFFSET_SCRATCH_BASE: u32 = ARENA_STATE_OFFSET_SCRATCH_CURSOR + 4;
141
142/// Byte offset of [`ArenaState::trap_code`]. The three trailing u32
143/// fields (`arena_len`, `tail_cursor`, `scratch_cursor`,
144/// `scratch_base`) total 16 bytes past `arena_base`; the `u64`
145/// `trap_code` follows on its natural 8-byte boundary. The
146/// `Op::CallNative` lowering reads / writes this offset; a runtime
147/// assert in [`ArenaState`]'s test module pins the layout.
148pub const ARENA_STATE_OFFSET_TRAP_CODE: u32 = 24;
149
150/// Byte offset of [`ArenaState::host_fns`]. The `usize`-wide registry
151/// pointer follows `trap_code` on its natural boundary. Only the Rust
152/// helper [`relon_llvm_call_native`] dereferences this field (via
153/// `state.host_fns.get()`), so the emitter never materialises the
154/// offset — it exists for the layout assertion + documentation.
155#[allow(dead_code)]
156pub const ARENA_STATE_OFFSET_HOST_FNS: u32 = ARENA_STATE_OFFSET_TRAP_CODE + 8;
157
158/// Byte offset of [`ArenaState::step_budget`]. Appended after the
159/// existing host-fn word so the earlier ABI offsets stay stable.
160pub const ARENA_STATE_OFFSET_STEP_BUDGET: u32 =
161    ARENA_STATE_OFFSET_HOST_FNS + std::mem::size_of::<usize>() as u32;
162
163/// Phase 0b native-dispatch trap codes recorded in
164/// [`ArenaState::trap_code`] by [`relon_llvm_call_native`]. Mirrors the
165/// cranelift backend's `TrapKind` numbering for the subset the LLVM
166/// dynamic-dispatch path can raise. `0` is reserved for "no trap".
167#[repr(u64)]
168#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169pub enum NativeTrap {
170    /// Division (`Op::Div` / `Op::Mod`) by zero. Matches cranelift's
171    /// `TrapKind::DivisionByZero` (= 1); lifts to
172    /// `RuntimeError::DivisionByZero`.
173    DivisionByZero = 1,
174    /// Pointer dereference walked past the arena bounds. Matches
175    /// cranelift's `TrapKind::BoundsViolation` (= 2); lifts to
176    /// `RuntimeError::IndexOutOfBounds`.
177    BoundsViolation = 2,
178    /// Per-call resource budget exhausted. LLVM currently raises this
179    /// from deterministic step-budget fuel; a future wall-clock deadline
180    /// can reuse the same trap code.
181    ResourceExhausted = 4,
182    /// The `Op::CheckCap` gate denied a gated native call (the granted
183    /// `caps` bitmask had the required bit clear). Matches cranelift's
184    /// `TrapKind::CapabilityDenied` (= 3); lifts to
185    /// `RuntimeError::CapabilityDenied`.
186    CapabilityDenied = 3,
187    /// A checked Int reduction overflowed i64 (`list_int_sum`'s
188    /// per-iteration guard). Matches cranelift's
189    /// `TrapKind::NumericOverflow` (= 6); lifts to
190    /// `RuntimeError::NumericOverflow`, the same typed error the
191    /// tree-walk oracle's checked `+` raises. Routed through
192    /// `state.trap_code` + the negative sentinel (not `llvm.trap`)
193    /// so the host can surface the typed error instead of a SIGILL.
194    NumericOverflow = 6,
195    /// No host fn registered at the requested `import_idx`, or no
196    /// registry installed at all. Surfaces as
197    /// `RuntimeError::Unsupported`. Matches cranelift's
198    /// `TrapKind::Unreachable` (= 5) so the host-observable outcome
199    /// class is identical across backends.
200    HostFnMissing = 5,
201    /// The host fn returned an error, or a value outside the phase-0b
202    /// scalar return envelope (`Int` / `Bool` / `Unit`). Surfaces as
203    /// `RuntimeError::Unsupported`. A distinct code from `HostFnMissing`
204    /// only for post-mortem readability — both lift to `Unsupported`.
205    HostFnError = 7,
206    /// A strict-mode `match` fell through every arm with no `_`
207    /// catch-all and no arm matched at runtime. Lifts to
208    /// `RuntimeError::TypeMismatch { expected: "a matching arm", .. }`,
209    /// byte-aligned (modulo range) with the tree-walk oracle and the
210    /// cranelift `TrapKind::NoMatch`. The LLVM `Op::Trap` path can't use
211    /// `llvm.trap` (a `ud2` SIGILL the host can't decode into a typed
212    /// error), so the no-match trap records this code in
213    /// `state.trap_code` + returns the negative sentinel, which
214    /// `run_buffer_main` already lifts via `runtime_error_from_code`.
215    NoMatch = 8,
216}
217
218impl NativeTrap {
219    /// Lift a trap code recorded in [`ArenaState::trap_code`] into the
220    /// matching [`RuntimeError`]. Unknown / `0` codes are treated as
221    /// `Unsupported` (defensive — the JIT only ever stores the codes
222    /// above). Mirrors cranelift's `TrapKind::to_runtime_error` for the
223    /// subset the LLVM dynamic-dispatch path raises.
224    pub fn runtime_error_from_code(code: u64) -> RuntimeError {
225        match code {
226            1 => RuntimeError::DivisionByZero(TokenRange::default()),
227            2 => RuntimeError::IndexOutOfBounds {
228                range: TokenRange::default(),
229            },
230            3 => RuntimeError::CapabilityDenied {
231                cap_bit: None,
232                reason: "llvm-aot: host-fn call denied by capability gate".to_string(),
233                range: TokenRange::default(),
234            },
235            // Checked-reduction overflow — same typed error class as the
236            // tree-walk oracle's checked `+` and cranelift's
237            // `TrapKind::NumericOverflow::to_runtime_error`.
238            6 => RuntimeError::NumericOverflow(TokenRange::default()),
239            8 => RuntimeError::TypeMismatch {
240                // Byte-aligned with the tree-walk oracle's `Expr::Match`
241                // no-match path and the cranelift `TrapKind::NoMatch`
242                // mapping. `found` cannot reproduce the oracle's
243                // value-dependent `format!("value {}", val)` from a static
244                // trap; it states the structural cause instead.
245                expected: "a matching arm".to_string(),
246                found: "no matching arm".to_string(),
247                range: TokenRange::default(),
248            },
249            4 => RuntimeError::StepLimitExceeded {
250                limit: None,
251                range: TokenRange::default(),
252            },
253            _ => RuntimeError::Unsupported {
254                reason: "llvm-aot: native-fn dispatch failed (host fn missing / errored / \
255                         returned a non-scalar value)"
256                    .to_string(),
257            },
258        }
259    }
260}
261
262impl ArenaState {
263    /// Construct a state that points at `arena[0..]` for a single
264    /// dispatch. The caller owns the backing storage; this struct
265    /// only borrows it through a raw pointer for the JIT's
266    /// lifetime.
267    ///
268    /// `scratch_base` is the arena-relative offset where temporary
269    /// allocations (string concat, ...) live; pass `arena.len()` to
270    /// disable the scratch path. The cursors are reset to 0 so the
271    /// JIT bump path starts fresh on every dispatch.
272    ///
273    /// # Safety
274    ///
275    /// The caller must keep `arena` live and exclusively owned by the
276    /// `run_main` invocation that consumes this state. The emitted
277    /// JIT code reads and writes through `arena_base` without
278    /// touching the Rust borrow checker.
279    pub fn new(arena: &mut [u8], scratch_base: u32) -> Self {
280        Self {
281            arena_base: UnsafeCell::new(arena.as_mut_ptr() as usize),
282            arena_len: UnsafeCell::new(arena.len() as u32),
283            tail_cursor: UnsafeCell::new(0),
284            scratch_cursor: UnsafeCell::new(0),
285            scratch_base: UnsafeCell::new(scratch_base),
286            trap_code: UnsafeCell::new(0),
287            host_fns: UnsafeCell::new(0),
288            step_budget: UnsafeCell::new(0),
289        }
290    }
291
292    /// Set the remaining step budget for this dispatch. `0` disables
293    /// budget checks; negative values are already exhausted.
294    pub fn set_step_budget(&self, budget: i64) {
295        unsafe {
296            *self.step_budget.get() = budget;
297        }
298    }
299
300    /// Point the state at a host-fn registry for the duration of one
301    /// dispatch. Pass `0` (or skip the call) to leave the registry
302    /// unset — a `CallNative` then traps `HostFnMissing`.
303    ///
304    /// # Safety
305    ///
306    /// `registry` must outlive the JIT dispatch that consumes this
307    /// state, and must be a valid `*const HostFnRegistry` (or null).
308    /// The per-call ownership model keeps the `UnsafeCell` unaliased.
309    pub unsafe fn install_host_fns(&self, registry: *const HostFnRegistry) {
310        unsafe {
311            *self.host_fns.get() = registry as usize;
312        }
313    }
314
315    /// Read the trap code recorded by the JIT-side `Op::CallNative`
316    /// dispatch. `0` means no native-dispatch trap fired.
317    pub fn trap_code(&self) -> u64 {
318        // SAFETY: the dispatch has returned, so the cell is unaliased.
319        unsafe { *self.trap_code.get() }
320    }
321
322    /// Read the current tail-cursor value. Used by the evaluator
323    /// after a dispatch returns to know how much was written into the
324    /// tail region (for `String` return-value decoding).
325    #[allow(dead_code)]
326    pub fn tail_cursor(&self) -> u32 {
327        // SAFETY: caller owns the state exclusively for a single
328        // dispatch — no aliasing read can happen.
329        unsafe { *self.tail_cursor.get() }
330    }
331}
332
333/// Phase 0b host-fn registry: `import_idx -> Arc<dyn RelonFunction>`.
334///
335/// Mirrors the `host_fns` half of the cranelift backend's
336/// `CapabilityVtable`. The LLVM evaluator owns one of these (built via
337/// [`Self::with_host_fns`]) and points each per-call [`ArenaState`] at
338/// it through [`ArenaState::install_host_fns`]; a source-lowered
339/// `Op::CallNative` then resolves the `import_idx`-keyed callable via
340/// [`relon_llvm_call_native`].
341///
342/// Keying off `import_idx` (the IR-side private namespace) keeps it
343/// distinct from the capability-bit namespace the `Op::CheckCap`
344/// gate consumes — exactly the cranelift split.
345#[derive(Default, Clone)]
346pub struct HostFnRegistry {
347    host_fns: HashMap<u32, Arc<dyn RelonFunction>>,
348}
349
350impl std::fmt::Debug for HostFnRegistry {
351    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
352        f.debug_struct("HostFnRegistry")
353            .field("host_fn_count", &self.host_fns.len())
354            .finish()
355    }
356}
357
358impl HostFnRegistry {
359    /// Build an empty registry.
360    pub fn new() -> Self {
361        Self {
362            host_fns: HashMap::new(),
363        }
364    }
365
366    /// Register a callable at `import_idx`. Overwrites any prior entry.
367    pub fn register(&mut self, import_idx: u32, func: Arc<dyn RelonFunction>) {
368        self.host_fns.insert(import_idx, func);
369    }
370
371    /// Resolve the callable registered at `import_idx`.
372    pub fn resolve(&self, import_idx: u32) -> Option<&Arc<dyn RelonFunction>> {
373        self.host_fns.get(&import_idx)
374    }
375
376    /// Number of registered host fns.
377    pub fn len(&self) -> usize {
378        self.host_fns.len()
379    }
380
381    /// `true` when no host fns are registered.
382    pub fn is_empty(&self) -> bool {
383        self.host_fns.is_empty()
384    }
385}
386
387/// Zero-surface [`NativeFnCaps`] for LLVM-dispatched host fns. Same
388/// envelope as the cranelift backend's `CraneliftNativeFnCaps`: no
389/// closure-callback / iterator surface yet, so a host fn that tries to
390/// call back into Relon logic gets a typed `Unsupported` error rather
391/// than a segfault. Cached as a single `Arc` so each dispatch is a
392/// refcount bump.
393struct LlvmNativeFnCaps;
394
395impl NativeFnCaps for LlvmNativeFnCaps {
396    fn call_relon(
397        &self,
398        _func: &Value,
399        _args: Vec<Value>,
400        _range: TokenRange,
401    ) -> Result<Value, RuntimeError> {
402        Err(RuntimeError::Unsupported {
403            reason: "llvm-aot host fn: call_relon callback unsupported".to_string(),
404        })
405    }
406}
407
408fn llvm_native_caps() -> Arc<dyn NativeFnCaps> {
409    static CAPS: std::sync::OnceLock<Arc<dyn NativeFnCaps>> = std::sync::OnceLock::new();
410    Arc::clone(CAPS.get_or_init(|| Arc::new(LlvmNativeFnCaps) as Arc<dyn NativeFnCaps>))
411}
412
413/// Stable symbol name the LLVM module declares the native-dispatch
414/// helper under. The evaluator maps it onto
415/// [`relon_llvm_call_native`]'s address via `engine.add_global_mapping`
416/// before resolving the entry pointer. Mirrors the cranelift backend's
417/// `RelonCallNative` vtable slot — same `(state, import_idx, args_ptr,
418/// arg_count) -> i64` shape, resolved by symbol here instead of through
419/// a data-vtable slot.
420pub const RELON_LLVM_CALL_NATIVE_SYMBOL: &str = "relon_llvm_call_native";
421
422/// Dynamic host-fn dispatch helper for a source-lowered
423/// `Op::CallNative`. The JIT-emitted call site passes the per-call
424/// `ArenaState` pointer, the IR `import_idx`, a pointer to `arg_count`
425/// contiguous i64 args (spilled into an `alloca` by the lowering), and
426/// the arg count. The helper:
427///
428/// 1. loads the `host_fns` registry pointer from the state;
429/// 2. resolves the `Arc<dyn RelonFunction>` registered at `import_idx`;
430/// 3. packs the i64 args as `Value::Int`s into `NativeArgs`;
431/// 4. invokes the callable and returns the i64-encoded scalar result.
432///
433/// Failures (no registry / no callable / host-fn error / non-scalar
434/// return) do **not** unwind across this `extern "C"` boundary (that
435/// would be UB on a `panic=unwind` build): the helper records a
436/// [`NativeTrap`] code in `state.trap_code` and returns `0`. The JIT
437/// call site loads `trap_code` right after the call and routes a
438/// non-zero value to an `llvm.trap`, so the host sees a typed
439/// `RuntimeError` the same way every other LLVM trap surfaces. Mirrors
440/// the cranelift backend's `SandboxState::call_native`.
441///
442/// Scope: scalar `Int` args in, `Int` / `Bool` / `Unit` result out.
443///
444/// # Safety
445///
446/// `state` must point at a live, aligned [`ArenaState`]; `args_ptr`
447/// must point at `arg_count` contiguous `i64`s. The JIT prologue passes
448/// the same `state` pointer it received and a stack slot it just
449/// populated, so both invariants hold for every production caller.
450pub unsafe extern "C" fn relon_llvm_call_native(
451    state: *const ArenaState,
452    import_idx: u32,
453    args_ptr: *const i64,
454    arg_count: u32,
455) -> i64 {
456    // SAFETY: caller guarantees a live, aligned ArenaState.
457    let state = unsafe { &*state };
458    // SAFETY: per-call ownership — the JIT thread is the only reader.
459    let registry_ptr = unsafe { *state.host_fns.get() } as *const HostFnRegistry;
460    let record_trap = |code: NativeTrap| {
461        // SAFETY: per-call ownership; the JIT call has not returned yet
462        // but no other thread can see this state.
463        unsafe {
464            *state.trap_code.get() = code as u64;
465        }
466    };
467    if registry_ptr.is_null() {
468        record_trap(NativeTrap::HostFnMissing);
469        return 0;
470    }
471    // SAFETY: the evaluator installs a registry that outlives the
472    // dispatch (it lives on the evaluator, behind an Arc).
473    let registry = unsafe { &*registry_ptr };
474    let Some(func) = registry.resolve(import_idx).cloned() else {
475        record_trap(NativeTrap::HostFnMissing);
476        return 0;
477    };
478    let args_slice = if arg_count == 0 {
479        &[][..]
480    } else {
481        // SAFETY: caller guarantees `arg_count` contiguous i64s.
482        unsafe { std::slice::from_raw_parts(args_ptr, arg_count as usize) }
483    };
484    let packed: Vec<Value> = args_slice.iter().map(|&x| Value::Int(x)).collect();
485    let native_args = NativeArgs::from_positional(packed, llvm_native_caps());
486    match func.call(native_args, TokenRange::default()) {
487        Ok(Value::Int(v)) => v,
488        Ok(Value::Bool(b)) => i64::from(b),
489        Ok(v) if v.is_option_none() => 0,
490        Ok(_) | Err(_) => {
491            record_trap(NativeTrap::HostFnError);
492            0
493        }
494    }
495}
496
497/// Address of [`relon_llvm_call_native`] as a `usize`, for
498/// `engine.add_global_mapping`. Two-step cast silences the
499/// `fn-as-usize` lint (mirrors `relon_llvm_str_contains_arena_addr`).
500#[inline]
501pub fn relon_llvm_call_native_addr() -> usize {
502    relon_llvm_call_native as *const () as usize
503}
504
505#[cfg(test)]
506mod tests {
507    use super::*;
508
509    #[test]
510    fn arena_state_offsets_match_repr_c_layout() {
511        let mut buf = [0u8; 16];
512        let state = ArenaState::new(&mut buf, 16);
513        let base = &state as *const _ as usize;
514        assert_eq!(
515            (state.arena_base.get() as usize) - base,
516            ARENA_STATE_OFFSET_BASE as usize
517        );
518        assert_eq!(
519            (state.arena_len.get() as usize) - base,
520            ARENA_STATE_OFFSET_LEN as usize
521        );
522        assert_eq!(
523            (state.tail_cursor.get() as usize) - base,
524            ARENA_STATE_OFFSET_TAIL_CURSOR as usize
525        );
526        assert_eq!(
527            (state.scratch_cursor.get() as usize) - base,
528            ARENA_STATE_OFFSET_SCRATCH_CURSOR as usize
529        );
530        assert_eq!(
531            (state.scratch_base.get() as usize) - base,
532            ARENA_STATE_OFFSET_SCRATCH_BASE as usize
533        );
534        assert_eq!(
535            (state.trap_code.get() as usize) - base,
536            ARENA_STATE_OFFSET_TRAP_CODE as usize
537        );
538        assert_eq!(
539            (state.host_fns.get() as usize) - base,
540            ARENA_STATE_OFFSET_HOST_FNS as usize
541        );
542        assert_eq!(
543            (state.step_budget.get() as usize) - base,
544            ARENA_STATE_OFFSET_STEP_BUDGET as usize
545        );
546    }
547
548    struct AddOne;
549    impl RelonFunction for AddOne {
550        fn call(&self, args: NativeArgs, _r: TokenRange) -> Result<Value, RuntimeError> {
551            match args.positional.first() {
552                Some(Value::Int(x)) => Ok(Value::Int(x + 1)),
553                _ => Err(RuntimeError::Unsupported {
554                    reason: "AddOne expects Int".into(),
555                }),
556            }
557        }
558    }
559
560    #[test]
561    fn call_native_helper_dispatches_registered_fn() {
562        let mut reg = HostFnRegistry::new();
563        reg.register(0, Arc::new(AddOne));
564        let mut buf = [0u8; 16];
565        let state = ArenaState::new(&mut buf, 16);
566        // SAFETY: `reg` outlives the call below.
567        unsafe { state.install_host_fns(&reg as *const _) };
568        let args = [41i64];
569        let r = unsafe { relon_llvm_call_native(&state as *const _, 0, args.as_ptr(), 1) };
570        assert_eq!(r, 42);
571        assert_eq!(state.trap_code(), 0);
572    }
573
574    #[test]
575    fn call_native_helper_traps_when_unregistered() {
576        let reg = HostFnRegistry::new();
577        let mut buf = [0u8; 16];
578        let state = ArenaState::new(&mut buf, 16);
579        unsafe { state.install_host_fns(&reg as *const _) };
580        let r = unsafe { relon_llvm_call_native(&state as *const _, 7, std::ptr::null(), 0) };
581        assert_eq!(r, 0);
582        assert_eq!(state.trap_code(), NativeTrap::HostFnMissing as u64);
583    }
584
585    #[test]
586    fn call_native_helper_traps_when_no_registry() {
587        let mut buf = [0u8; 16];
588        let state = ArenaState::new(&mut buf, 16);
589        // No install_host_fns — registry pointer stays null.
590        let r = unsafe { relon_llvm_call_native(&state as *const _, 0, std::ptr::null(), 0) };
591        assert_eq!(r, 0);
592        assert_eq!(state.trap_code(), NativeTrap::HostFnMissing as u64);
593    }
594
595    #[test]
596    fn native_trap_bounds_code_lifts_to_index_out_of_bounds() {
597        assert!(matches!(
598            NativeTrap::runtime_error_from_code(NativeTrap::DivisionByZero as u64),
599            RuntimeError::DivisionByZero(_)
600        ));
601        assert!(matches!(
602            NativeTrap::runtime_error_from_code(NativeTrap::BoundsViolation as u64),
603            RuntimeError::IndexOutOfBounds { .. }
604        ));
605        assert!(matches!(
606            NativeTrap::runtime_error_from_code(NativeTrap::ResourceExhausted as u64),
607            RuntimeError::StepLimitExceeded { .. }
608        ));
609    }
610}