Skip to main content

stryke/
scope.rs

1use std::collections::{HashMap, HashSet};
2use std::sync::Arc;
3
4use indexmap::IndexMap;
5use parking_lot::{Mutex, RwLock};
6
7use crate::ast::PerlTypeName;
8use crate::error::StrykeError;
9use crate::value::StrykeValue;
10
11/// Thread-safe shared array for `mysync @a`.
12#[derive(Debug, Clone)]
13pub struct AtomicArray(pub Arc<Mutex<Vec<StrykeValue>>>);
14
15/// Thread-safe shared hash for `mysync %h`.
16#[derive(Debug, Clone)]
17pub struct AtomicHash(pub Arc<Mutex<IndexMap<String, StrykeValue>>>);
18
19type ScopeCaptureWithAtomics = (
20    Vec<(String, StrykeValue)>,
21    Vec<(String, AtomicArray)>,
22    Vec<(String, AtomicHash)>,
23);
24
25/// Storage for hashes promoted to shared Arc-backed RwLocks (see [`Frame::shared_hashes`]).
26/// Aliased to keep the field declaration readable (clippy::type_complexity).
27type SharedHashEntry = (
28    String,
29    Arc<parking_lot::RwLock<IndexMap<String, StrykeValue>>>,
30);
31
32/// `main` is the default package — `$main::X` ≡ `$X`, `@main::INC` ≡
33/// `@INC`, `%main::ENV` ≡ `%ENV`. Storage uses the bare key, so every
34/// scope getter has to short-circuit `main::name` (with no further
35/// `::`) through the unqualified lookup. Returns the bare suffix when
36/// the name has the exact `main::ident` shape; `None` otherwise (incl.
37/// `main::Pkg::name`, where `Pkg` is a real subpackage that must keep
38/// its qualified storage key).
39#[inline]
40pub(crate) fn strip_main_prefix(name: &str) -> Option<&str> {
41    let rest = name.strip_prefix("main::")?;
42    if rest.contains("::") {
43        return None;
44    }
45    Some(rest)
46}
47
48/// Canonicalize a `main::name` query into the bare `name` form.
49/// Shadow-binds `$name` so the rest of the function body operates on
50/// the canonical key. No allocation — the borrow is reused.
51macro_rules! canon_main {
52    ($name:ident) => {
53        let $name: &str = $crate::scope::strip_main_prefix($name).unwrap_or($name);
54    };
55}
56
57/// Arrays installed by [`crate::vm_helper::VMHelper::new`] on the outer frame. They must not be
58/// copied into [`Scope::capture`] / [`Scope::restore_capture`] for closures, or the restored copy
59/// would shadow the live handles (stale `@INC`, `%ENV`, topic `@_`, etc.).
60#[inline]
61fn capture_skip_bootstrap_array(name: &str) -> bool {
62    matches!(
63        name,
64        "INC" | "ARGV" | "_" | "-" | "+" | "^CAPTURE" | "^CAPTURE_ALL"
65    )
66}
67
68/// Hashes installed at interpreter bootstrap (same rationale as [`capture_skip_bootstrap_array`]).
69#[inline]
70fn capture_skip_bootstrap_hash(name: &str) -> bool {
71    matches!(name, "INC" | "ENV" | "SIG" | "^HOOK")
72}
73
74/// Parse a positional topic-slot scalar name (no leading sigil) and return the
75/// slot index N. Recognizes `_N` and the outer-chain forms `_N<`, `_N<<`,
76/// `_N<<<`, `_N<<<<`. Returns `None` for `_`, `_<`, etc. (slot 0, which never
77/// needs to bump `max_active_slot`).
78#[inline]
79/// Return the alias name for a topic-variant if `_` ↔ `_0` apply at any
80/// chain level. `_` ↔ `_0`, `_<` ↔ `_0<`, `_<<<` ↔ `_0<<<`, etc. Anything
81/// else (`_1`, `_2<<`, …) has no alias (those are positional-only slots).
82fn topic_alias(name: &str) -> Option<String> {
83    if name == "_" {
84        return Some("_0".to_string());
85    }
86    if name == "_0" {
87        return Some("_".to_string());
88    }
89    // _<+ → _0<+, _0<+ → _<+
90    if let Some(rest) = name.strip_prefix("_0") {
91        if rest.chars().all(|c| c == '<') && !rest.is_empty() {
92            return Some(format!("_{rest}"));
93        }
94    }
95    if let Some(rest) = name.strip_prefix('_') {
96        if rest.chars().all(|c| c == '<') && !rest.is_empty() {
97            return Some(format!("_0{rest}"));
98        }
99    }
100    None
101}
102
103fn parse_positional_topic_slot(name: &str) -> Option<usize> {
104    let bytes = name.as_bytes();
105    if bytes.len() < 2 || bytes[0] != b'_' || !bytes[1].is_ascii_digit() {
106        return None;
107    }
108    let mut i = 1;
109    while i < bytes.len() && bytes[i].is_ascii_digit() {
110        i += 1;
111    }
112    let digits = &name[1..i];
113    while i < bytes.len() && bytes[i] == b'<' {
114        i += 1;
115    }
116    if i != bytes.len() {
117        return None;
118    }
119    digits.parse().ok().filter(|&n: &usize| n >= 1)
120}
121
122/// Saved bindings for `local $x` / `local @a` / `local %h` — restored on [`Scope::pop_frame`].
123#[derive(Clone, Debug)]
124enum LocalRestore {
125    Scalar(String, StrykeValue),
126    Array(String, Vec<StrykeValue>),
127    Hash(String, IndexMap<String, StrykeValue>),
128    /// `local $h{k}` — third is `None` if the key was absent before `local` (restore deletes the key).
129    HashElement(String, String, Option<StrykeValue>),
130    /// `local $a[i]` — restore previous slot value (see [`Scope::local_set_array_element`]).
131    ArrayElement(String, i64, StrykeValue),
132}
133
134/// A single lexical scope frame.
135/// Uses Vec instead of HashMap — for typical Perl code with < 10 variables per
136/// scope, linear scan is faster than hashing due to cache locality and zero
137/// hash overhead.
138#[derive(Debug, Clone)]
139struct Frame {
140    scalars: Vec<(String, StrykeValue)>,
141    arrays: Vec<(String, Vec<StrykeValue>)>,
142    /// Subroutine (or bootstrap) `@_` — stored separately so call paths can move the arg
143    /// [`Vec`] into the frame without an extra copy via [`Frame::arrays`].
144    sub_underscore: Option<Vec<StrykeValue>>,
145    hashes: Vec<(String, IndexMap<String, StrykeValue>)>,
146    /// Slot-indexed scalars for O(1) access from compiled subroutines.
147    /// Compiler assigns `my $x` declarations a u8 slot index; the VM accesses
148    /// `scalar_slots[idx]` directly without name lookup or frame walking.
149    scalar_slots: Vec<StrykeValue>,
150    /// Bare scalar name for each slot (same index as `scalar_slots`) — for [`Scope::capture`]
151    /// / closures when the binding exists only in `scalar_slots`.
152    scalar_slot_names: Vec<Option<String>>,
153    /// Dynamic `local` saves — applied in reverse when this frame is popped.
154    local_restores: Vec<LocalRestore>,
155    /// Lexical names from `frozen my $x` / `@a` / `%h` (bare name, same as storage key).
156    frozen_scalars: HashSet<String>,
157    frozen_arrays: HashSet<String>,
158    frozen_hashes: HashSet<String>,
159    /// `typed my $x : Int` — runtime type checks on assignment.
160    typed_scalars: HashMap<String, PerlTypeName>,
161    /// Arrays promoted to shared Arc-backed storage by `\@arr`.
162    /// When a ref is taken, both the scope and the ref share the same Arc,
163    /// so mutations through either path are visible. Re-declaration removes the entry.
164    shared_arrays: Vec<(String, Arc<parking_lot::RwLock<Vec<StrykeValue>>>)>,
165    /// Hashes promoted to shared Arc-backed storage by `\%hash`.
166    shared_hashes: Vec<SharedHashEntry>,
167    /// Thread-safe arrays from `mysync @a`
168    atomic_arrays: Vec<(String, AtomicArray)>,
169    /// Thread-safe hashes from `mysync %h`
170    atomic_hashes: Vec<(String, AtomicHash)>,
171    /// `defer { BLOCK }` closures to run when this frame is popped (LIFO order).
172    defers: Vec<StrykeValue>,
173    /// True after the first [`Scope::set_topic`] call in this frame. Subsequent
174    /// calls (the next iter of the SAME `map`/`grep`/etc.) skip the chain shift
175    /// so `_<` keeps pointing at the enclosing scope's topic instead of rolling
176    /// to the previous iter's value. Reset by [`Self::clear_all_bindings`] when
177    /// the frame is recycled. `set_closure_args` does NOT set this flag — sub
178    /// entry shifts are real outer-topic captures, not iter re-entries.
179    set_topic_called: bool,
180}
181
182impl Frame {
183    /// Drop all lexical bindings so blessed objects run `DESTROY` when frames are recycled
184    /// ([`Scope::pop_frame`]) or reused ([`Scope::push_frame`]).
185    #[inline]
186    fn clear_all_bindings(&mut self) {
187        self.scalars.clear();
188        self.arrays.clear();
189        self.sub_underscore = None;
190        self.hashes.clear();
191        self.scalar_slots.clear();
192        self.scalar_slot_names.clear();
193        self.local_restores.clear();
194        self.frozen_scalars.clear();
195        self.frozen_arrays.clear();
196        self.frozen_hashes.clear();
197        self.typed_scalars.clear();
198        self.shared_arrays.clear();
199        self.shared_hashes.clear();
200        self.atomic_arrays.clear();
201        self.defers.clear();
202        self.atomic_hashes.clear();
203        self.set_topic_called = false;
204    }
205
206    /// True if this slot index is a real binding (not vec padding before a higher-index declare).
207    /// Anonymous temps use [`Option::Some`] with an empty string so slot ops do not fall through
208    /// to an outer frame's same slot index.
209    #[inline]
210    fn owns_scalar_slot_index(&self, idx: usize) -> bool {
211        self.scalar_slot_names.get(idx).is_some_and(|n| n.is_some())
212    }
213
214    #[inline]
215    fn new() -> Self {
216        Self {
217            scalars: Vec::new(),
218            arrays: Vec::new(),
219            sub_underscore: None,
220            hashes: Vec::new(),
221            scalar_slots: Vec::new(),
222            scalar_slot_names: Vec::new(),
223            frozen_scalars: HashSet::new(),
224            frozen_arrays: HashSet::new(),
225            frozen_hashes: HashSet::new(),
226            shared_arrays: Vec::new(),
227            shared_hashes: Vec::new(),
228            typed_scalars: HashMap::new(),
229            atomic_arrays: Vec::new(),
230            atomic_hashes: Vec::new(),
231            local_restores: Vec::new(),
232            defers: Vec::new(),
233            set_topic_called: false,
234        }
235    }
236
237    #[inline]
238    fn get_scalar(&self, name: &str) -> Option<&StrykeValue> {
239        let name = strip_main_prefix(name).unwrap_or(name);
240        if let Some(v) = self.get_scalar_from_slot(name) {
241            return Some(v);
242        }
243        self.scalars.iter().find(|(k, _)| k == name).map(|(_, v)| v)
244    }
245
246    /// O(N) scan over slot names — only used by `get_scalar` fallback (name-based lookup);
247    /// hot compiled paths use `get_scalar_slot(idx)` directly.
248    #[inline]
249    fn get_scalar_from_slot(&self, name: &str) -> Option<&StrykeValue> {
250        let name = strip_main_prefix(name).unwrap_or(name);
251        for (i, sn) in self.scalar_slot_names.iter().enumerate() {
252            if let Some(ref n) = sn {
253                if n == name {
254                    return self.scalar_slots.get(i);
255                }
256            }
257        }
258        None
259    }
260
261    #[inline]
262    fn has_scalar(&self, name: &str) -> bool {
263        let name = strip_main_prefix(name).unwrap_or(name);
264        if self
265            .scalar_slot_names
266            .iter()
267            .any(|sn| sn.as_deref() == Some(name))
268        {
269            return true;
270        }
271        self.scalars.iter().any(|(k, _)| k == name)
272    }
273
274    #[inline]
275    fn set_scalar(&mut self, name: &str, val: StrykeValue) {
276        let name = strip_main_prefix(name).unwrap_or(name);
277        for (i, sn) in self.scalar_slot_names.iter().enumerate() {
278            if let Some(ref n) = sn {
279                if n == name {
280                    if i < self.scalar_slots.len() {
281                        // Write through CaptureCell so closures sharing this cell see the update
282                        if let Some(r) = self.scalar_slots[i].as_capture_cell() {
283                            *r.write() = val;
284                        } else {
285                            self.scalar_slots[i] = val;
286                        }
287                    }
288                    return;
289                }
290            }
291        }
292        if let Some(entry) = self.scalars.iter_mut().find(|(k, _)| k == name) {
293            // Write through CaptureCell so closures sharing this cell see the update
294            if let Some(r) = entry.1.as_capture_cell() {
295                *r.write() = val;
296            } else {
297                entry.1 = val;
298            }
299        } else {
300            self.scalars.push((name.to_string(), val));
301        }
302    }
303
304    /// Topic-slot variant: REPLACE the slot's value without writing
305    /// through any existing CaptureCell. Used by `shift_slot_chain` /
306    /// `declare_topic_slot` so binding the per-call arg to `$_`/`$_0`
307    /// doesn't mutate a closure-captured cell shared with the outer
308    /// scope. Without this, every call of a closure would clobber the
309    /// caller's `$_` with the call's first arg, and `$_<` inside HOF
310    /// blocks would alias the iter value rather than the surrounding
311    /// scope's topic.
312    #[inline]
313    fn set_scalar_raw(&mut self, name: &str, val: StrykeValue) {
314        let name = strip_main_prefix(name).unwrap_or(name);
315        for (i, sn) in self.scalar_slot_names.iter().enumerate() {
316            if let Some(ref n) = sn {
317                if n == name {
318                    if i < self.scalar_slots.len() {
319                        self.scalar_slots[i] = val;
320                    }
321                    return;
322                }
323            }
324        }
325        if let Some(entry) = self.scalars.iter_mut().find(|(k, _)| k == name) {
326            entry.1 = val;
327        } else {
328            self.scalars.push((name.to_string(), val));
329        }
330    }
331
332    #[inline]
333    fn get_array(&self, name: &str) -> Option<&Vec<StrykeValue>> {
334        let name = strip_main_prefix(name).unwrap_or(name);
335        if name == "_" {
336            if let Some(ref v) = self.sub_underscore {
337                return Some(v);
338            }
339        }
340        self.arrays.iter().find(|(k, _)| k == name).map(|(_, v)| v)
341    }
342
343    #[inline]
344    fn has_array(&self, name: &str) -> bool {
345        let name = strip_main_prefix(name).unwrap_or(name);
346        if name == "_" && self.sub_underscore.is_some() {
347            return true;
348        }
349        self.arrays.iter().any(|(k, _)| k == name)
350            || self.shared_arrays.iter().any(|(k, _)| k == name)
351    }
352
353    #[inline]
354    fn get_array_mut(&mut self, name: &str) -> Option<&mut Vec<StrykeValue>> {
355        let name = strip_main_prefix(name).unwrap_or(name);
356        if name == "_" {
357            return self.sub_underscore.as_mut();
358        }
359        self.arrays
360            .iter_mut()
361            .find(|(k, _)| k == name)
362            .map(|(_, v)| v)
363    }
364
365    #[inline]
366    fn set_array(&mut self, name: &str, val: Vec<StrykeValue>) {
367        let name = strip_main_prefix(name).unwrap_or(name);
368        if name == "_" {
369            if let Some(pos) = self.arrays.iter().position(|(k, _)| k == name) {
370                self.arrays.swap_remove(pos);
371            }
372            self.sub_underscore = Some(val);
373            return;
374        }
375        if let Some(entry) = self.arrays.iter_mut().find(|(k, _)| k == name) {
376            entry.1 = val;
377        } else {
378            self.arrays.push((name.to_string(), val));
379        }
380    }
381
382    #[inline]
383    fn get_hash(&self, name: &str) -> Option<&IndexMap<String, StrykeValue>> {
384        let name = strip_main_prefix(name).unwrap_or(name);
385        self.hashes.iter().find(|(k, _)| k == name).map(|(_, v)| v)
386    }
387
388    #[inline]
389    fn has_hash(&self, name: &str) -> bool {
390        let name = strip_main_prefix(name).unwrap_or(name);
391        self.hashes.iter().any(|(k, _)| k == name)
392            || self.shared_hashes.iter().any(|(k, _)| k == name)
393    }
394
395    #[inline]
396    fn get_hash_mut(&mut self, name: &str) -> Option<&mut IndexMap<String, StrykeValue>> {
397        let name = strip_main_prefix(name).unwrap_or(name);
398        self.hashes
399            .iter_mut()
400            .find(|(k, _)| k == name)
401            .map(|(_, v)| v)
402    }
403
404    #[inline]
405    fn set_hash(&mut self, name: &str, val: IndexMap<String, StrykeValue>) {
406        let name = strip_main_prefix(name).unwrap_or(name);
407        if let Some(entry) = self.hashes.iter_mut().find(|(k, _)| k == name) {
408            entry.1 = val;
409        } else {
410            self.hashes.push((name.to_string(), val));
411        }
412    }
413}
414
415/// Manages lexical scoping with a stack of frames.
416/// Innermost frame is last in the vector.
417#[derive(Debug, Clone)]
418pub struct Scope {
419    /// `frames` field.
420    frames: Vec<Frame>,
421    /// Recycled frames to avoid allocation on every push_frame/pop_frame cycle.
422    frame_pool: Vec<Frame>,
423    /// When true (rayon worker / parallel block), reject writes to outer captured lexicals unless
424    /// the binding is `mysync` (atomic) or a loop topic (`$_`, `$a`, `$b`). Package names with `::`
425    /// are exempt. Requires at least two frames (captured + block locals); use [`Self::push_frame`]
426    /// before running a block body on a worker.
427    parallel_guard: bool,
428    /// Frame depth at the moment `parallel_guard` was enabled. Frames at depth
429    /// `>= parallel_guard_baseline` were pushed AFTER the guard turned on, so
430    /// they are worker-local and writable; frames at depth `< baseline` are the
431    /// captured outer scope and writes to those need `mysync`. Without this,
432    /// any nested block (e.g. `for my $y (@x) { ... }` inside `pmap`) would
433    /// push a new frame that makes `@x` look "captured" relative to the
434    /// innermost frame, even though `@x` was declared INSIDE the worker's own
435    /// block. Reset to 0 when the guard is disabled.
436    parallel_guard_baseline: usize,
437    /// Highest positional slot index ever activated by [`Self::set_closure_args`].
438    /// Once a slot is touched, every subsequent frame shifts that slot's outer
439    /// chain (`_N<`, `_N<<`, ...) even if the new frame has fewer args. This
440    /// is what makes `_N<<<<` reach 4 frames up consistently — intermediate
441    /// frames with no slot N still propagate the chain (with `undef` if they
442    /// didn't bind that slot themselves).
443    max_active_slot: usize,
444}
445
446impl Default for Scope {
447    fn default() -> Self {
448        Self::new()
449    }
450}
451
452impl Scope {
453    /// `new` — see implementation.
454    pub fn new() -> Self {
455        let mut s = Self {
456            frames: Vec::with_capacity(32),
457            frame_pool: Vec::with_capacity(32),
458            parallel_guard: false,
459            parallel_guard_baseline: 0,
460            max_active_slot: 0,
461        };
462        s.frames.push(Frame::new());
463        s
464    }
465
466    /// Enable [`Self::parallel_guard`] for parallel worker interpreters (pmap, fan, …).
467    /// Snapshots the current frame depth as the baseline — any frames pushed
468    /// after this call are worker-local and writable; frames already present
469    /// are the captured outer scope.
470    #[inline]
471    pub fn set_parallel_guard(&mut self, enabled: bool) {
472        self.parallel_guard = enabled;
473        self.parallel_guard_baseline = if enabled { self.frames.len() } else { 0 };
474    }
475    /// `parallel_guard` — see implementation.
476    #[inline]
477    pub fn parallel_guard(&self) -> bool {
478        self.parallel_guard
479    }
480
481    /// Names allowed to mutate freely inside a parallel block. Excludes plain
482    /// package-qualified names (`Pkg::x`) — those used to be unconditionally skipped,
483    /// but that was the source of plain `our $x` silently accumulating per-worker
484    /// copies under `fan`/`pmap`/`pfor`. The atomicity check in
485    /// [`Self::check_parallel_scalar_write`] now decides: `oursync` (Atomic-backed) is
486    /// allowed, plain `our` (non-atomic) errors with a directive to declare `oursync`.
487    #[inline]
488    fn parallel_skip_special_name(_name: &str) -> bool {
489        false
490    }
491
492    /// Loop/sort topic scalars that parallel ops assign before each iteration.
493    #[inline]
494    fn parallel_allowed_topic_scalar(name: &str) -> bool {
495        matches!(name, "_" | "a" | "b")
496    }
497
498    /// Regex / runtime scratch arrays live on an outer frame; parallel match still mutates them.
499    #[inline]
500    fn parallel_allowed_internal_array(name: &str) -> bool {
501        matches!(name, "-" | "+" | "^CAPTURE" | "^CAPTURE_ALL")
502    }
503
504    /// `%ENV`, `%INC`, and regex named-capture hashes `"+"` / `"-"` — same outer-frame issue as internal arrays.
505    #[inline]
506    fn parallel_allowed_internal_hash(name: &str) -> bool {
507        matches!(name, "+" | "-" | "ENV" | "INC")
508    }
509
510    fn check_parallel_scalar_write(&self, name: &str) -> Result<(), StrykeError> {
511        if !self.parallel_guard || Self::parallel_skip_special_name(name) {
512            return Ok(());
513        }
514        if Self::parallel_allowed_topic_scalar(name) {
515            return Ok(());
516        }
517        if crate::special_vars::is_regex_match_scalar_name(name) {
518            return Ok(());
519        }
520        // Worker-local frames are at depth >= baseline; any frame at that
521        // depth or deeper is fine to write (it was created by THIS worker's
522        // block, even if a nested for/if/sub pushed an inner frame after it).
523        let baseline = self.parallel_guard_baseline;
524        for (i, frame) in self.frames.iter().enumerate().rev() {
525            if frame.has_scalar(name) {
526                if let Some(v) = frame.get_scalar(name) {
527                    if v.as_atomic_arc().is_some() {
528                        return Ok(());
529                    }
530                }
531                if i < baseline {
532                    // Direct the user to the right shared-state primitive based on
533                    // whether the captured variable is package-global (`our` →
534                    // `oursync`) or lexical (`my` → `mysync`).
535                    let directive = if name.contains("::") {
536                        "declare `oursync` for shared package-global state"
537                    } else {
538                        "declare `mysync` for shared lexical state"
539                    };
540                    return Err(StrykeError::runtime(
541                        format!(
542                            "cannot assign to captured non-atomic variable `${}` in a parallel block — {}",
543                            name, directive
544                        ),
545                        0,
546                    ));
547                }
548                return Ok(());
549            }
550        }
551        Err(StrykeError::runtime(
552            format!(
553                "cannot assign to undeclared variable `${}` in a parallel block",
554                name
555            ),
556            0,
557        ))
558    }
559    /// `depth` — see implementation.
560    #[inline]
561    pub fn depth(&self) -> usize {
562        self.frames.len()
563    }
564
565    /// Pop frames until we're at `target_depth`. Used by VM ReturnValue
566    /// to cleanly unwind through if/while/for blocks on return.
567    #[inline]
568    pub fn pop_to_depth(&mut self, target_depth: usize) {
569        while self.frames.len() > target_depth && self.frames.len() > 1 {
570            self.pop_frame();
571        }
572    }
573    /// `push_frame` — see implementation.
574    #[inline]
575    pub fn push_frame(&mut self) {
576        if let Some(mut frame) = self.frame_pool.pop() {
577            frame.clear_all_bindings();
578            self.frames.push(frame);
579        } else {
580            self.frames.push(Frame::new());
581        }
582    }
583
584    // ── Frame-local scalar slots (O(1) access for compiled subs) ──
585
586    /// Read scalar from slot — innermost binding for `slot` wins (same index can exist on nested
587    /// frames; padding entries without [`Frame::owns_scalar_slot_index`] do not shadow outers).
588    #[inline]
589    pub fn get_scalar_slot(&self, slot: u8) -> StrykeValue {
590        let idx = slot as usize;
591        for frame in self.frames.iter().rev() {
592            if idx < frame.scalar_slots.len() && frame.owns_scalar_slot_index(idx) {
593                let val = &frame.scalar_slots[idx];
594                // Transparently unwrap CaptureCell (closure-captured variable) — read through
595                // the shared lock. User-created ScalarRef from `\expr` is NOT unwrapped.
596                if let Some(arc) = val.as_capture_cell() {
597                    return arc.read().clone();
598                }
599                return val.clone();
600            }
601        }
602        StrykeValue::UNDEF
603    }
604
605    /// Write scalar to slot — innermost binding for `slot` wins (see [`Self::get_scalar_slot`]).
606    #[inline]
607    pub fn set_scalar_slot(&mut self, slot: u8, val: StrykeValue) {
608        let idx = slot as usize;
609        let len = self.frames.len();
610        for i in (0..len).rev() {
611            if idx < self.frames[i].scalar_slots.len() && self.frames[i].owns_scalar_slot_index(idx)
612            {
613                // Write through CaptureCell so closures sharing this cell see the update
614                if let Some(r) = self.frames[i].scalar_slots[idx].as_capture_cell() {
615                    *r.write() = val;
616                } else {
617                    self.frames[i].scalar_slots[idx] = val;
618                }
619                return;
620            }
621        }
622        let top = self.frames.last_mut().unwrap();
623        top.scalar_slots.resize(idx + 1, StrykeValue::UNDEF);
624        if idx >= top.scalar_slot_names.len() {
625            top.scalar_slot_names.resize(idx + 1, None);
626        }
627        top.scalar_slot_names[idx] = Some(String::new());
628        top.scalar_slots[idx] = val;
629    }
630
631    /// Like [`set_scalar_slot`] but respects the parallel guard — returns `Err` when assigning
632    /// to a slot that belongs to an outer frame inside a parallel block.  `slot_name` is resolved
633    /// from the bytecode's name table by the caller when available.
634    #[inline]
635    pub fn set_scalar_slot_checked(
636        &mut self,
637        slot: u8,
638        val: StrykeValue,
639        slot_name: Option<&str>,
640    ) -> Result<(), StrykeError> {
641        if self.parallel_guard {
642            let idx = slot as usize;
643            let len = self.frames.len();
644            let top_has = idx < self.frames[len - 1].scalar_slots.len()
645                && self.frames[len - 1].owns_scalar_slot_index(idx);
646            if !top_has {
647                let name_owned: String = {
648                    let mut found = String::new();
649                    for i in (0..len).rev() {
650                        if let Some(Some(n)) = self.frames[i].scalar_slot_names.get(idx) {
651                            found = n.clone();
652                            break;
653                        }
654                    }
655                    if found.is_empty() {
656                        if let Some(sn) = slot_name {
657                            found = sn.to_string();
658                        }
659                    }
660                    found
661                };
662                let name = name_owned.as_str();
663                if !name.is_empty() && !Self::parallel_allowed_topic_scalar(name) {
664                    let baseline = self.parallel_guard_baseline;
665                    for (fi, frame) in self.frames.iter().enumerate().rev() {
666                        if frame.has_scalar(name)
667                            || (idx < frame.scalar_slots.len() && frame.owns_scalar_slot_index(idx))
668                        {
669                            if fi < baseline {
670                                return Err(StrykeError::runtime(
671                                    format!(
672                                        "cannot assign to captured outer lexical `${}` inside a parallel block (use `mysync`)",
673                                        name
674                                    ),
675                                    0,
676                                ));
677                            }
678                            break;
679                        }
680                    }
681                }
682            }
683        }
684        self.set_scalar_slot(slot, val);
685        Ok(())
686    }
687
688    /// Declare + initialize scalar in the current frame's slot array.
689    /// `name` (bare identifier, e.g. `x` for `$x`) is stored for [`Scope::capture`] when the
690    /// binding is slot-only (no duplicate `frame.scalars` row).
691    #[inline]
692    pub fn declare_scalar_slot(&mut self, slot: u8, val: StrykeValue, name: Option<&str>) {
693        let idx = slot as usize;
694        let frame = self.frames.last_mut().unwrap();
695        if idx >= frame.scalar_slots.len() {
696            frame.scalar_slots.resize(idx + 1, StrykeValue::UNDEF);
697        }
698        frame.scalar_slots[idx] = val;
699        if idx >= frame.scalar_slot_names.len() {
700            frame.scalar_slot_names.resize(idx + 1, None);
701        }
702        match name {
703            Some(n) => frame.scalar_slot_names[idx] = Some(n.to_string()),
704            // Anonymous slot: mark occupied so padding holes don't shadow parent frame slots.
705            None => frame.scalar_slot_names[idx] = Some(String::new()),
706        }
707    }
708
709    /// Slot-indexed `.=` — avoids frame walking and string comparison on every iteration.
710    ///
711    /// Returns a [`StrykeValue::shallow_clone`] (Arc::clone) of the stored value
712    /// rather than a full [`Clone`], which would deep-copy the entire `String`
713    /// payload and turn a `$s .= "x"` loop into O(N²) memcpy.
714    /// Repeated `$slot .= rhs` fused-loop fast path: locates the slot's frame once,
715    /// tries `try_concat_repeat_inplace` (unique heap-String → single `reserve`+`push_str`
716    /// burst), and returns `true` on success. Returns `false` when the slot is not a
717    /// uniquely-held `String` so the caller can fall back to the per-iteration slow
718    /// path. Called from `Op::ConcatConstSlotLoop`.
719    #[inline]
720    pub fn scalar_slot_concat_repeat_inplace(&mut self, slot: u8, rhs: &str, n: usize) -> bool {
721        let idx = slot as usize;
722        let len = self.frames.len();
723        let fi = {
724            let mut found = len - 1;
725            if idx >= self.frames[found].scalar_slots.len()
726                || !self.frames[found].owns_scalar_slot_index(idx)
727            {
728                for i in (0..len - 1).rev() {
729                    if idx < self.frames[i].scalar_slots.len()
730                        && self.frames[i].owns_scalar_slot_index(idx)
731                    {
732                        found = i;
733                        break;
734                    }
735                }
736            }
737            found
738        };
739        let frame = &mut self.frames[fi];
740        if idx >= frame.scalar_slots.len() {
741            frame.scalar_slots.resize(idx + 1, StrykeValue::UNDEF);
742        }
743        frame.scalar_slots[idx].try_concat_repeat_inplace(rhs, n)
744    }
745
746    /// Slow fallback for the fused string-append loop: clones the RHS into a new
747    /// `StrykeValue::string` once and runs the existing `scalar_slot_concat_inplace`
748    /// path `n` times. Used by `Op::ConcatConstSlotLoop` when the slot is aliased
749    /// and the in-place fast path rejected the mutation.
750    #[inline]
751    pub fn scalar_slot_concat_repeat_slow(&mut self, slot: u8, rhs: &str, n: usize) {
752        let pv = StrykeValue::string(rhs.to_owned());
753        for _ in 0..n {
754            let _ = self.scalar_slot_concat_inplace(slot, &pv);
755        }
756    }
757    /// `scalar_slot_concat_inplace` — see implementation.
758    #[inline]
759    pub fn scalar_slot_concat_inplace(&mut self, slot: u8, rhs: &StrykeValue) -> StrykeValue {
760        let idx = slot as usize;
761        let len = self.frames.len();
762        let fi = {
763            let mut found = len - 1;
764            if idx >= self.frames[found].scalar_slots.len()
765                || !self.frames[found].owns_scalar_slot_index(idx)
766            {
767                for i in (0..len - 1).rev() {
768                    if idx < self.frames[i].scalar_slots.len()
769                        && self.frames[i].owns_scalar_slot_index(idx)
770                    {
771                        found = i;
772                        break;
773                    }
774                }
775            }
776            found
777        };
778        let frame = &mut self.frames[fi];
779        if idx >= frame.scalar_slots.len() {
780            frame.scalar_slots.resize(idx + 1, StrykeValue::UNDEF);
781        }
782        // Fast path: when the slot holds the only `Arc<HeapObject::String>` handle,
783        // extend the underlying `String` buffer in place — no Arc alloc, no full
784        // unwrap/rewrap. This turns a `$s .= "x"` loop into `String::push_str` only.
785        // The shallow_clone handle that goes back onto the VM stack briefly bumps
786        // the refcount to 2, so the NEXT iteration's fast path would fail — except
787        // the VM immediately `Pop`s that handle (or `ConcatAppendSlotVoid` never
788        // pushes it), restoring unique ownership before the next `.=`.
789        if frame.scalar_slots[idx].try_concat_append_inplace(rhs) {
790            return frame.scalar_slots[idx].shallow_clone();
791        }
792        let new_val = std::mem::replace(&mut frame.scalar_slots[idx], StrykeValue::UNDEF)
793            .concat_append_owned(rhs);
794        let handle = new_val.shallow_clone();
795        frame.scalar_slots[idx] = new_val;
796        handle
797    }
798
799    #[inline]
800    pub(crate) fn can_pop_frame(&self) -> bool {
801        self.frames.len() > 1
802    }
803    /// `pop_frame` — see implementation.
804    #[inline]
805    pub fn pop_frame(&mut self) {
806        if self.frames.len() > 1 {
807            let mut frame = self.frames.pop().expect("pop_frame");
808            // Local restore must write outer bindings even when parallel_guard is on
809            // (user code cannot mutate captured vars; unwind is not user mutation).
810            let saved_guard = self.parallel_guard;
811            self.parallel_guard = false;
812            for entry in frame.local_restores.drain(..).rev() {
813                match entry {
814                    LocalRestore::Scalar(name, old) => {
815                        let _ = self.set_scalar(&name, old);
816                    }
817                    LocalRestore::Array(name, old) => {
818                        let _ = self.set_array(&name, old);
819                    }
820                    LocalRestore::Hash(name, old) => {
821                        let _ = self.set_hash(&name, old);
822                    }
823                    LocalRestore::HashElement(name, key, old) => match old {
824                        Some(v) => {
825                            let _ = self.set_hash_element(&name, &key, v);
826                        }
827                        None => {
828                            let _ = self.delete_hash_element(&name, &key);
829                        }
830                    },
831                    LocalRestore::ArrayElement(name, index, old) => {
832                        let _ = self.set_array_element(&name, index, old);
833                    }
834                }
835            }
836            self.parallel_guard = saved_guard;
837            frame.clear_all_bindings();
838            // Return frame to pool for reuse (avoids allocation on next push_frame).
839            if self.frame_pool.len() < 64 {
840                self.frame_pool.push(frame);
841            }
842        }
843    }
844
845    /// `local $name` — save current value, assign `val`; restore on `pop_frame`.
846    pub fn local_set_scalar(&mut self, name: &str, val: StrykeValue) -> Result<(), StrykeError> {
847        let old = self.get_scalar(name);
848        if let Some(frame) = self.frames.last_mut() {
849            frame
850                .local_restores
851                .push(LocalRestore::Scalar(name.to_string(), old));
852        }
853        self.set_scalar(name, val)
854    }
855
856    /// `local @name` — not valid for `mysync` arrays.
857    pub fn local_set_array(
858        &mut self,
859        name: &str,
860        val: Vec<StrykeValue>,
861    ) -> Result<(), StrykeError> {
862        if self.find_atomic_array(name).is_some() {
863            return Err(StrykeError::runtime(
864                "local cannot be used on mysync arrays",
865                0,
866            ));
867        }
868        let old = self.get_array(name);
869        if let Some(frame) = self.frames.last_mut() {
870            frame
871                .local_restores
872                .push(LocalRestore::Array(name.to_string(), old));
873        }
874        self.set_array(name, val)?;
875        Ok(())
876    }
877
878    /// `local %name`
879    pub fn local_set_hash(
880        &mut self,
881        name: &str,
882        val: IndexMap<String, StrykeValue>,
883    ) -> Result<(), StrykeError> {
884        if self.find_atomic_hash(name).is_some() {
885            return Err(StrykeError::runtime(
886                "local cannot be used on mysync hashes",
887                0,
888            ));
889        }
890        let old = self.get_hash(name);
891        if let Some(frame) = self.frames.last_mut() {
892            frame
893                .local_restores
894                .push(LocalRestore::Hash(name.to_string(), old));
895        }
896        self.set_hash(name, val)?;
897        Ok(())
898    }
899
900    /// `local $h{key} = val` — save key state; restore one slot on `pop_frame`.
901    pub fn local_set_hash_element(
902        &mut self,
903        name: &str,
904        key: &str,
905        val: StrykeValue,
906    ) -> Result<(), StrykeError> {
907        if self.find_atomic_hash(name).is_some() {
908            return Err(StrykeError::runtime(
909                "local cannot be used on mysync hash elements",
910                0,
911            ));
912        }
913        let old = if self.exists_hash_element(name, key) {
914            Some(self.get_hash_element(name, key))
915        } else {
916            None
917        };
918        if let Some(frame) = self.frames.last_mut() {
919            frame.local_restores.push(LocalRestore::HashElement(
920                name.to_string(),
921                key.to_string(),
922                old,
923            ));
924        }
925        self.set_hash_element(name, key, val)?;
926        Ok(())
927    }
928
929    /// `local $a[i] = val` — save element (as returned by [`Self::get_array_element`]), assign;
930    /// restore on [`Self::pop_frame`].
931    pub fn local_set_array_element(
932        &mut self,
933        name: &str,
934        index: i64,
935        val: StrykeValue,
936    ) -> Result<(), StrykeError> {
937        if self.find_atomic_array(name).is_some() {
938            return Err(StrykeError::runtime(
939                "local cannot be used on mysync array elements",
940                0,
941            ));
942        }
943        let old = self.get_array_element(name, index);
944        if let Some(frame) = self.frames.last_mut() {
945            frame
946                .local_restores
947                .push(LocalRestore::ArrayElement(name.to_string(), index, old));
948        }
949        self.set_array_element(name, index, val)?;
950        Ok(())
951    }
952
953    // ── Scalars ──
954    /// `declare_scalar` — see implementation.
955    #[inline]
956    pub fn declare_scalar(&mut self, name: &str, val: StrykeValue) {
957        let _ = self.declare_scalar_frozen(name, val, false, None);
958    }
959
960    /// Declare a lexical scalar; `frozen` means no further assignment to this binding.
961    /// `ty` is from `typed my $x : Int` — enforced on every assignment.
962    pub fn declare_scalar_frozen(
963        &mut self,
964        name: &str,
965        val: StrykeValue,
966        frozen: bool,
967        ty: Option<PerlTypeName>,
968    ) -> Result<(), StrykeError> {
969        canon_main!(name);
970        if let Some(ref t) = ty {
971            t.check_value(&val)
972                .map_err(|msg| StrykeError::type_error(format!("`${}`: {}", name, msg), 0))?;
973        }
974        if let Some(frame) = self.frames.last_mut() {
975            // Use `set_scalar_raw` instead of `set_scalar` so an existing slot
976            // that holds a closure-captured `CaptureCell` gets OVERWRITTEN with
977            // a fresh value rather than WRITTEN-THROUGH the cell. Without this,
978            // a `val $msg = "inner"` inside a closure body whose outer frame
979            // declared `val $msg = "outer"` would dispatch through `set_scalar`,
980            // find the captured `$msg` slot, see its CaptureCell, and clobber
981            // the outer's binding via the shared cell — defeating closure
982            // shadow semantics. `my $x` already avoids this because it uses
983            // slot-direct `DeclareScalarSlot` → `declare_scalar_slot` which
984            // assigns into `scalar_slots[idx]` raw.
985            frame.set_scalar_raw(name, val);
986            if frozen {
987                frame.frozen_scalars.insert(name.to_string());
988            } else {
989                // Re-declaring as non-frozen unfreezes — matches the array
990                // path's behavior. Without this, a sibling top-level `{...}`
991                // block that re-binds the same name with `var` after a `val`
992                // in another block still sees the frozen flag (top-level
993                // blocks don't push a new frame, so the file frame carries
994                // the flag across blocks).
995                frame.frozen_scalars.remove(name);
996            }
997            if let Some(t) = ty {
998                frame.typed_scalars.insert(name.to_string(), t);
999            }
1000        }
1001        Ok(())
1002    }
1003
1004    /// True if the innermost lexical scalar binding for `name` is `frozen`.
1005    pub fn is_scalar_frozen(&self, name: &str) -> bool {
1006        for frame in self.frames.iter().rev() {
1007            if frame.has_scalar(name) {
1008                return frame.frozen_scalars.contains(name);
1009            }
1010        }
1011        false
1012    }
1013
1014    /// True if the innermost lexical array binding for `name` is `frozen`.
1015    pub fn is_array_frozen(&self, name: &str) -> bool {
1016        for frame in self.frames.iter().rev() {
1017            if frame.has_array(name) {
1018                return frame.frozen_arrays.contains(name);
1019            }
1020        }
1021        false
1022    }
1023
1024    /// True if the innermost lexical hash binding for `name` is `frozen`.
1025    pub fn is_hash_frozen(&self, name: &str) -> bool {
1026        for frame in self.frames.iter().rev() {
1027            if frame.has_hash(name) {
1028                return frame.frozen_hashes.contains(name);
1029            }
1030        }
1031        false
1032    }
1033
1034    /// Returns Some(sigil) if the named variable is frozen, None if mutable.
1035    pub fn check_frozen(&self, sigil: &str, name: &str) -> Option<&'static str> {
1036        match sigil {
1037            "$" => {
1038                if self.is_scalar_frozen(name) {
1039                    Some("scalar")
1040                } else {
1041                    None
1042                }
1043            }
1044            "@" => {
1045                if self.is_array_frozen(name) {
1046                    Some("array")
1047                } else {
1048                    None
1049                }
1050            }
1051            "%" => {
1052                if self.is_hash_frozen(name) {
1053                    Some("hash")
1054                } else {
1055                    None
1056                }
1057            }
1058            _ => None,
1059        }
1060    }
1061    /// `get_scalar` — see implementation.
1062    #[inline]
1063    pub fn get_scalar(&self, name: &str) -> StrykeValue {
1064        // `$main::X` aliases the bare `$X` (default-package equivalence).
1065        if let Some(rest) = strip_main_prefix(name) {
1066            return self.get_scalar(rest);
1067        }
1068        for frame in self.frames.iter().rev() {
1069            if let Some(val) = frame.get_scalar(name) {
1070                // Transparently unwrap Atomic — read through the lock
1071                if let Some(arc) = val.as_atomic_arc() {
1072                    return arc.lock().clone();
1073                }
1074                // Transparently unwrap CaptureCell (closure-captured variable) — read through the lock.
1075                // User-created ScalarRef from `\expr` is NOT unwrapped.
1076                if let Some(arc) = val.as_capture_cell() {
1077                    return arc.read().clone();
1078                }
1079                // Topic-slot chain (`_<`, `_<<`, `_N<+`, `_0<+`, …) reports
1080                // the value at the requested ascent level verbatim. No
1081                // fallback to current `_`: if no enclosing topic frame
1082                // populated that level, the chain entry is undef and
1083                // `_<` returns undef. This matches the documented
1084                // semantics of "walk N frames up the topic chain".
1085                return val.clone();
1086            }
1087        }
1088        StrykeValue::UNDEF
1089    }
1090
1091    /// True for ANY topic-variant name: `_`, `_<+`, `_N`, `_N<+`. Matches
1092    /// the regex `^_[0-9]*<*$`. User assignments to these names are
1093    /// routed through the raw-write path so they stay frame-local rather
1094    /// than propagating up via closure-shared CaptureCells. Topic
1095    /// variants are framework-managed positional aliases — mutating them
1096    /// inside a block must NOT leak to the surrounding scope, otherwise
1097    /// per-iter HOF body mutations would chaotically mutate the caller's
1098    /// `$_`/`$_N` and break the lexical-outer chain invariant.
1099    #[inline]
1100    pub(crate) fn is_topic_variant_name(name: &str) -> bool {
1101        let bytes = name.as_bytes();
1102        if bytes.is_empty() || bytes[0] != b'_' {
1103            return false;
1104        }
1105        let mut i = 1;
1106        while i < bytes.len() && bytes[i].is_ascii_digit() {
1107            i += 1;
1108        }
1109        while i < bytes.len() && bytes[i] == b'<' {
1110            i += 1;
1111        }
1112        i == bytes.len()
1113    }
1114
1115    /// True if any frame has a lexical scalar binding for `name` (`my` / `our` / assignment).
1116    #[inline]
1117    pub fn scalar_binding_exists(&self, name: &str) -> bool {
1118        canon_main!(name);
1119        for frame in self.frames.iter().rev() {
1120            if frame.has_scalar(name) {
1121                return true;
1122            }
1123        }
1124        false
1125    }
1126
1127    /// Collect all scalar variable names across all frames (for debugger).
1128    pub fn all_scalar_names(&self) -> Vec<String> {
1129        let mut names = Vec::new();
1130        for frame in &self.frames {
1131            for (name, _) in &frame.scalars {
1132                if !names.contains(name) {
1133                    names.push(name.clone());
1134                }
1135            }
1136            for name in frame.scalar_slot_names.iter().flatten() {
1137                if !names.contains(name) {
1138                    names.push(name.clone());
1139                }
1140            }
1141        }
1142        names
1143    }
1144
1145    /// Names of every array binding visible across frames (deduplicated).
1146    pub fn all_array_names(&self) -> Vec<String> {
1147        let mut names = Vec::new();
1148        for frame in &self.frames {
1149            for (name, _) in &frame.arrays {
1150                if !names.contains(name) {
1151                    names.push(name.clone());
1152                }
1153            }
1154            for (name, _) in &frame.shared_arrays {
1155                if !names.contains(name) {
1156                    names.push(name.clone());
1157                }
1158            }
1159            for (name, _) in &frame.atomic_arrays {
1160                if !names.contains(name) {
1161                    names.push(name.clone());
1162                }
1163            }
1164        }
1165        names
1166    }
1167
1168    /// Names of every hash binding visible across frames (deduplicated).
1169    pub fn all_hash_names(&self) -> Vec<String> {
1170        let mut names = Vec::new();
1171        for frame in &self.frames {
1172            for (name, _) in &frame.hashes {
1173                if !names.contains(name) {
1174                    names.push(name.clone());
1175                }
1176            }
1177            for (name, _) in &frame.shared_hashes {
1178                if !names.contains(name) {
1179                    names.push(name.clone());
1180                }
1181            }
1182            for (name, _) in &frame.atomic_hashes {
1183                if !names.contains(name) {
1184                    names.push(name.clone());
1185                }
1186            }
1187        }
1188        names
1189    }
1190
1191    /// True if any frame or atomic slot holds an array named `name`.
1192    #[inline]
1193    pub fn array_binding_exists(&self, name: &str) -> bool {
1194        canon_main!(name);
1195        if self.find_atomic_array(name).is_some() {
1196            return true;
1197        }
1198        for frame in self.frames.iter().rev() {
1199            if frame.has_array(name) {
1200                return true;
1201            }
1202        }
1203        false
1204    }
1205
1206    /// True if any frame or atomic slot holds a hash named `name`.
1207    #[inline]
1208    pub fn hash_binding_exists(&self, name: &str) -> bool {
1209        if let Some(rest) = strip_main_prefix(name) {
1210            return self.hash_binding_exists(rest);
1211        }
1212        if self.find_atomic_hash(name).is_some() {
1213            return true;
1214        }
1215        for frame in self.frames.iter().rev() {
1216            if frame.has_hash(name) {
1217                return true;
1218            }
1219        }
1220        false
1221    }
1222
1223    /// Get the raw scalar value WITHOUT unwrapping Atomic.
1224    /// Used by scope.capture() to preserve the Arc for sharing across threads.
1225    #[inline]
1226    pub fn get_scalar_raw(&self, name: &str) -> StrykeValue {
1227        if let Some(rest) = strip_main_prefix(name) {
1228            return self.get_scalar_raw(rest);
1229        }
1230        for frame in self.frames.iter().rev() {
1231            if let Some(val) = frame.get_scalar(name) {
1232                return val.clone();
1233            }
1234        }
1235        StrykeValue::UNDEF
1236    }
1237
1238    /// Atomically read-modify-write a scalar. Holds the Mutex lock for
1239    /// the entire cycle so `mysync` variables are race-free under `fan`/`pfor`.
1240    /// Returns the NEW value. Returns `Err` when the parallel guard rejects the
1241    /// write — `++`/`+=`/`-=` on a captured non-atomic outer-scope variable now
1242    /// fails fast just like plain `=` does, instead of silently dropping writes.
1243    pub fn atomic_mutate(
1244        &mut self,
1245        name: &str,
1246        f: impl FnOnce(&StrykeValue) -> StrykeValue,
1247    ) -> Result<StrykeValue, StrykeError> {
1248        for frame in self.frames.iter().rev() {
1249            if let Some(v) = frame.get_scalar(name) {
1250                if let Some(arc) = v.as_atomic_arc() {
1251                    let mut guard = arc.lock();
1252                    let old = guard.clone();
1253                    let new_val = f(&guard);
1254                    *guard = new_val.clone();
1255                    crate::parallel_trace::emit_scalar_mutation(name, &old, &new_val);
1256                    return Ok(new_val);
1257                }
1258            }
1259        }
1260        // Non-atomic fallback. Route through `set_scalar` so the parallel guard
1261        // fires on `our` / `my` writes from inside `fan` / `pmap` / `pfor`.
1262        let old = self.get_scalar(name);
1263        let new_val = f(&old);
1264        self.set_scalar(name, new_val.clone())?;
1265        Ok(new_val)
1266    }
1267
1268    /// Like [`Self::atomic_mutate`] but returns the OLD value (for postfix `$x++`).
1269    /// Returns `Err` for non-atomic captured-outer writes inside parallel blocks
1270    /// (same DESIGN-001 strict-error path as `atomic_mutate`).
1271    pub fn atomic_mutate_post(
1272        &mut self,
1273        name: &str,
1274        f: impl FnOnce(&StrykeValue) -> StrykeValue,
1275    ) -> Result<StrykeValue, StrykeError> {
1276        for frame in self.frames.iter().rev() {
1277            if let Some(v) = frame.get_scalar(name) {
1278                if let Some(arc) = v.as_atomic_arc() {
1279                    let mut guard = arc.lock();
1280                    let old = guard.clone();
1281                    let new_val = f(&old);
1282                    *guard = new_val.clone();
1283                    crate::parallel_trace::emit_scalar_mutation(name, &old, &new_val);
1284                    return Ok(old);
1285                }
1286            }
1287        }
1288        // Non-atomic fallback — same parallel-guard semantics as `atomic_mutate`.
1289        let old = self.get_scalar(name);
1290        self.set_scalar(name, f(&old))?;
1291        Ok(old)
1292    }
1293
1294    /// Append `rhs` to a scalar string in-place (no clone of the existing string).
1295    /// If the scalar is not yet a String, it is converted first.
1296    ///
1297    /// The binding and the returned [`StrykeValue`] share the same heap [`Arc`] via
1298    /// [`StrykeValue::shallow_clone`] on the store — a full [`Clone`] would deep-copy the
1299    /// entire `String` each time and make repeated `.=` O(N²) in the total length.
1300    #[inline]
1301    pub fn scalar_concat_inplace(
1302        &mut self,
1303        name: &str,
1304        rhs: &StrykeValue,
1305    ) -> Result<StrykeValue, StrykeError> {
1306        canon_main!(name);
1307        self.check_parallel_scalar_write(name)?;
1308        for frame in self.frames.iter_mut().rev() {
1309            if let Some(entry) = frame.scalars.iter_mut().find(|(k, _)| k == name) {
1310                // `mysync $x` stores `HeapObject::Atomic` — must mutate under the mutex, not
1311                // `into_string()` the wrapper (that would stringify the cell, not the payload).
1312                if let Some(atomic_arc) = entry.1.as_atomic_arc() {
1313                    let mut guard = atomic_arc.lock();
1314                    let inner = std::mem::replace(&mut *guard, StrykeValue::UNDEF);
1315                    let new_val = inner.concat_append_owned(rhs);
1316                    *guard = new_val.shallow_clone();
1317                    return Ok(new_val);
1318                }
1319                // Fast path: same `Arc::get_mut` trick as the slot variant — mutate the
1320                // underlying `String` directly when the scalar is the lone handle.
1321                if entry.1.try_concat_append_inplace(rhs) {
1322                    return Ok(entry.1.shallow_clone());
1323                }
1324                // Use `into_string` + `append_to` so heap strings take the `Arc::try_unwrap`
1325                // fast path instead of `Display` / heap formatting on every `.=`.
1326                let new_val =
1327                    std::mem::replace(&mut entry.1, StrykeValue::UNDEF).concat_append_owned(rhs);
1328                entry.1 = new_val.shallow_clone();
1329                return Ok(new_val);
1330            }
1331        }
1332        // Variable not found — create as new string
1333        let val = StrykeValue::UNDEF.concat_append_owned(rhs);
1334        self.frames[0].set_scalar(name, val.shallow_clone());
1335        Ok(val)
1336    }
1337    /// `set_scalar` — see implementation.
1338    #[inline]
1339    pub fn set_scalar(&mut self, name: &str, val: StrykeValue) -> Result<(), StrykeError> {
1340        if let Some(rest) = strip_main_prefix(name) {
1341            return self.set_scalar(rest, val);
1342        }
1343        self.check_parallel_scalar_write(name)?;
1344        // Topic variants (`_`, `_<+`, `_N`, `_N<+`) are framework-managed
1345        // positional aliases. User assignments to them stay LOCAL to the
1346        // current frame — never propagate up through closure-shared
1347        // CaptureCells. Without this guard, a per-iter mutation inside a
1348        // HOF block would clobber the surrounding scope's `$_`/`$_N` and
1349        // break the "topic chain is lexical, not iterative" invariant.
1350        if Self::is_topic_variant_name(name) {
1351            if let Some(frame) = self.frames.last_mut() {
1352                frame.set_scalar_raw(name, val.clone());
1353                // Documented language invariant: `$_` and `$_0` are the same
1354                // variable (the topic ≡ positional slot 0). For deeper
1355                // chain levels they're also aliased: `$_<` ≡ `$_0<`, etc.
1356                // Mirror the write so reads of either name return the same
1357                // value. This fixes for-loop bindings where the foreach
1358                // compile path emits `Op::SetScalarPlain("_")` — without the
1359                // mirror, `$_0` stays undef.
1360                if let Some(alias) = topic_alias(name) {
1361                    frame.set_scalar_raw(&alias, val);
1362                }
1363            }
1364            return Ok(());
1365        }
1366        for frame in self.frames.iter_mut().rev() {
1367            // If the existing value is Atomic, write through the lock
1368            if let Some(v) = frame.get_scalar(name) {
1369                if let Some(arc) = v.as_atomic_arc() {
1370                    let mut guard = arc.lock();
1371                    let old = guard.clone();
1372                    *guard = val.clone();
1373                    crate::parallel_trace::emit_scalar_mutation(name, &old, &val);
1374                    return Ok(());
1375                }
1376                // If the existing value is CaptureCell (closure-captured variable), write through it
1377                if let Some(arc) = v.as_capture_cell() {
1378                    *arc.write() = val;
1379                    return Ok(());
1380                }
1381            }
1382            if frame.has_scalar(name) {
1383                if let Some(ty) = frame.typed_scalars.get(name) {
1384                    ty.check_value(&val).map_err(|msg| {
1385                        StrykeError::type_error(format!("`${}`: {}", name, msg), 0)
1386                    })?;
1387                }
1388                frame.set_scalar(name, val);
1389                return Ok(());
1390            }
1391        }
1392        self.frames[0].set_scalar(name, val);
1393        Ok(())
1394    }
1395
1396    /// Topic-slot key for slot N at chain level L (0 = current, 1..5 = outer
1397    /// frames). Slot 0's canonical form is bare `_` / `_<` / `_<<` / ... so
1398    /// direct `$_ = …` assignments and existing `$_<` consumers see the
1399    /// expected key. The `_0<+` form is the alias, written in lockstep by
1400    /// `declare_topic_slot`. For slot N ≥ 1 the canonical key is `_N<+`.
1401    #[inline]
1402    fn topic_slot_key(slot: usize, level: usize) -> String {
1403        debug_assert!(level <= 5);
1404        if slot == 0 {
1405            if level == 0 {
1406                "_".to_string()
1407            } else {
1408                format!("_{}", "<".repeat(level))
1409            }
1410        } else if level == 0 {
1411            format!("_{}", slot)
1412        } else {
1413            format!("_{}{}", slot, "<".repeat(level))
1414        }
1415    }
1416
1417    /// Mirror key for slot 0 (`_0` / `_0<` / `_0<<` / ...) — the explicit-zero
1418    /// alias of the bare form. Returns `None` for slot N ≥ 1 (no alias).
1419    #[inline]
1420    fn topic_slot_alias_key(slot: usize, level: usize) -> Option<String> {
1421        if slot != 0 {
1422            return None;
1423        }
1424        Some(if level == 0 {
1425            "_0".to_string()
1426        } else {
1427            format!("_0{}", "<".repeat(level))
1428        })
1429    }
1430
1431    /// Write a value at slot N, level L. For slot 0 also writes the bare-`_`
1432    /// mirror at the same level so `_<<<<` ≡ `_0<<<<` resolve to the same
1433    /// scalar. This is what makes the world-first multi-level implicit-param
1434    /// matrix work — see `lexer.rs` for the lexing side and the user-visible
1435    /// rule "_< ≡ $_< ≡ _0< ≡ $_0<".
1436    #[inline]
1437    fn declare_topic_slot(&mut self, slot: usize, level: usize, val: StrykeValue) {
1438        // Use `set_scalar_raw` (frame method) so binding the topic does
1439        // NOT write through a closure-captured CaptureCell. Without this,
1440        // every per-iter HOF block call would clobber the surrounding
1441        // scope's `$_` with the iter value via the shared cell — making
1442        // `$_<` alias the iter value rather than the lexical outer's
1443        // topic. The chain semantics require frame-isolated writes.
1444        if let Some(frame) = self.frames.last_mut() {
1445            let key = Self::topic_slot_key(slot, level);
1446            frame.set_scalar_raw(&key, val.clone());
1447            if let Some(alias) = Self::topic_slot_alias_key(slot, level) {
1448                frame.set_scalar_raw(&alias, val);
1449            }
1450        }
1451    }
1452
1453    /// Set the topic variable `$_` and its numeric alias `$_0` together.
1454    /// Use this for **block-form** closures (`map { ... }`, `grep { ... }`,
1455    /// `sort { ... }`, threaded `~> @arr map { ... }`, `fi { ... }`,
1456    /// etc.) so `$_`, `$_0`, and the outer-topic chain (`$_<`, `$_<<`, …)
1457    /// all behave correctly. EXPR-form HOFs (`grep EXPR, LIST`,
1458    /// `map EXPR, LIST`, `reject EXPR, LIST`, `grepv EXPR, LIST`, etc. —
1459    /// anything with no `{}`) MUST use [`Self::set_topic_local`] instead;
1460    /// EXPR position is in the same lexical scope as the surrounding code,
1461    /// so there is no scope/frame boundary and the chain MUST NOT shift.
1462    /// User-facing rule: **`{}` triggers the shift; no `{}` means no shift**.
1463    ///
1464    /// This declares `$_`/`$_0` in the current scope (not global), suitable
1465    /// for sub calls.
1466    ///
1467    /// Shifts the outer-topic chain (`$_<`, `$_<<`, `$_<<<`, `$_<<<<`,
1468    /// `$_<<<<<`) on the FIRST call in a given frame so nested blocks can
1469    /// peek up to 5 frames out. Subsequent calls in the same frame (the next iteration of the
1470    /// SAME `map`/`grep`/etc.) only refresh `_` and `_0` so `_<` keeps
1471    /// pointing at the **enclosing scope's** topic, not the previous
1472    /// iteration's value. This is the "frame-based" reading: from inside a
1473    /// nested closure, `_<` means "the topic of the closure that contains
1474    /// me" (which is constant across my iterations), not "the topic the
1475    /// previous iter set" (which would roll). All previously-activated
1476    /// positional slots shift in lockstep on the first call.
1477    #[inline]
1478    pub fn set_topic(&mut self, val: StrykeValue) {
1479        // Iteration re-entry detection: the per-frame `set_topic_called` flag
1480        // is true if a previous `set_topic` already shifted in this frame
1481        // (i.e. we're in the next iter of the SAME loop). Refresh `_` / `_0`
1482        // only — preserve the chain so the enclosing scope's topic stays
1483        // visible. `set_closure_args` does NOT set this flag, so a sub call
1484        // followed by `map { ... }` still gets a real shift on the FIRST
1485        // map iter (the chain becomes "the sub's args at level 1").
1486        let already_shifted = self
1487            .frames
1488            .last()
1489            .map(|f| f.set_topic_called)
1490            .unwrap_or(false);
1491        if already_shifted {
1492            self.declare_topic_slot(0, 0, val);
1493            for slot in 1..=self.max_active_slot {
1494                self.declare_topic_slot(slot, 0, StrykeValue::UNDEF);
1495            }
1496            return;
1497        }
1498        if let Some(frame) = self.frames.last_mut() {
1499            frame.set_topic_called = true;
1500        }
1501        self.shift_slot_chain(0, val);
1502        for slot in 1..=self.max_active_slot {
1503            self.shift_slot_chain(slot, StrykeValue::UNDEF);
1504        }
1505    }
1506
1507    /// EXPR-form variant: rebinds `$_` / `$_0` to `val` for the current
1508    /// iteration WITHOUT shifting any chain or zeroing slot 1+ aliases.
1509    /// Used by `grep EXPR, LIST` / `map EXPR, LIST` and the streaming
1510    /// equivalents — the EXPR is evaluated in the lexical scope of the
1511    /// surrounding code, with no block boundary, so the topic chain
1512    /// shouldn't roll. Crucially this preserves `_1`, `_2`, ..., `_N`
1513    /// from the caller fn so patterns like `grep _1, @$_` work without
1514    /// chain-ascent.
1515    #[inline]
1516    pub fn set_topic_local(&mut self, val: StrykeValue) {
1517        self.declare_topic_slot(0, 0, val);
1518    }
1519
1520    /// Set numeric closure argument aliases `$_0`, `$_1`, `$_2`, ... for all
1521    /// args. Also sets `$_` to the first argument (if any) and shifts the
1522    /// outer-topic chain on EVERY positional slot ever activated, so a 5-deep
1523    /// nested block can read `_2<<<<<` to reach the third positional argument
1524    /// from 5 frames up. (Stryke-only — no other language has nested implicit
1525    /// positionals.)
1526    ///
1527    /// The shift fires on slots `0..=max(args.len()-1, max_active_slot)`. A
1528    /// frame that binds fewer args than the high-water mark still rotates the
1529    /// older slots (the new "current" for an unbound slot is `undef`, so old
1530    /// values march through `_N<<<<` and eventually fall off the end).
1531    #[inline]
1532    pub fn set_closure_args(&mut self, args: &[StrykeValue]) {
1533        let n = args.len();
1534        if n == 0 {
1535            return;
1536        }
1537        let high = n.saturating_sub(1).max(self.max_active_slot);
1538        for slot in 0..=high {
1539            let val = args.get(slot).cloned().unwrap_or(StrykeValue::UNDEF);
1540            self.shift_slot_chain(slot, val);
1541        }
1542        if n > 0 && n - 1 > self.max_active_slot {
1543            self.max_active_slot = n - 1;
1544        }
1545    }
1546
1547    /// Shift slot N's outer-topic chain by one level and install `val` as the
1548    /// new current value. Internal helper for [`set_topic`] / [`set_closure_args`].
1549    ///
1550    /// Writes ALL 6 levels unconditionally — even when the previous values
1551    /// are `undef` — so chain semantics stay intact across frames that don't
1552    /// bind every active slot. Without the unconditional write, a stale value
1553    /// at `_N<` would persist across multiple "no slot N here" frames and
1554    /// `_N<<<<<` would never reach 5 frames back.
1555    #[inline]
1556    fn shift_slot_chain(&mut self, slot: usize, val: StrykeValue) {
1557        let l4 = self.get_scalar(&Self::topic_slot_key(slot, 4));
1558        let l3 = self.get_scalar(&Self::topic_slot_key(slot, 3));
1559        let l2 = self.get_scalar(&Self::topic_slot_key(slot, 2));
1560        let l1 = self.get_scalar(&Self::topic_slot_key(slot, 1));
1561        let cur = self.get_scalar(&Self::topic_slot_key(slot, 0));
1562
1563        self.declare_topic_slot(slot, 0, val);
1564        self.declare_topic_slot(slot, 1, cur);
1565        self.declare_topic_slot(slot, 2, l1);
1566        self.declare_topic_slot(slot, 3, l2);
1567        self.declare_topic_slot(slot, 4, l3);
1568        self.declare_topic_slot(slot, 5, l4);
1569    }
1570
1571    /// Set the canonical sort/reduce binding pair: `$a` / `$b` (Perl-isms) AND
1572    /// `$_0` / `$_1` (the stryke positional aliases — preferred under
1573    /// `--no-interop` because the `$a`/`$b` pair is inconsistent — there is
1574    /// no `$c`). The bareword forms `_0` / `_1` resolve to `$_0` / `$_1` via
1575    /// the parser, so blocks like `sort { _0 <=> _1 }` and `reduce { _0 + _1 }`
1576    /// just work. Use this helper anywhere the legacy code wrote two adjacent
1577    /// `set_scalar("a", …); set_scalar("b", …)` lines.
1578    #[inline]
1579    pub fn set_sort_pair(&mut self, a: StrykeValue, b: StrykeValue) {
1580        let _ = self.set_scalar("a", a.clone());
1581        let _ = self.set_scalar("b", b.clone());
1582        // Use `declare_topic_slot` so slot 0 (`_` / `$_` / `_0` / `$_0`)
1583        // and slot 1 (`_1` / `$_1`) become real topic slots in the
1584        // current frame — not just CaptureCell scalars. In a pmap /
1585        // spawn worker the per-item topic is set up via `set_topic`
1586        // (declares only slot 0); writing `_1` through plain
1587        // `set_scalar` lands in a non-slot scalar that the comparator
1588        // block's frame walk doesn't reach, so `sort { _ <=> _1 }`
1589        // silently returns the list unsorted. Routing both through
1590        // the same slot-declaration path that `set_topic` uses makes
1591        // `_/_1` work identically inside and outside parallel
1592        // workers — matches the Perl `sort { $a <=> $b }` rebinding.
1593        self.declare_topic_slot(0, 0, a);
1594        self.declare_topic_slot(1, 0, b);
1595    }
1596
1597    /// Save the entire topic slot 0 chain (`$_`, `$_<`, `$_<<`, ...) so it can
1598    /// be restored after a block that corrupts it (like `sort { ... }`).
1599    /// Returns a 6-element array [level0..level5].
1600    #[inline]
1601    pub fn save_topic_chain(&self) -> [StrykeValue; 6] {
1602        [
1603            self.get_scalar(&Self::topic_slot_key(0, 0)),
1604            self.get_scalar(&Self::topic_slot_key(0, 1)),
1605            self.get_scalar(&Self::topic_slot_key(0, 2)),
1606            self.get_scalar(&Self::topic_slot_key(0, 3)),
1607            self.get_scalar(&Self::topic_slot_key(0, 4)),
1608            self.get_scalar(&Self::topic_slot_key(0, 5)),
1609        ]
1610    }
1611
1612    /// Restore the topic slot 0 chain from a previous [`save_topic_chain`] call.
1613    #[inline]
1614    pub fn restore_topic_chain(&mut self, saved: [StrykeValue; 6]) {
1615        for (level, val) in saved.into_iter().enumerate() {
1616            self.declare_topic_slot(0, level, val);
1617        }
1618    }
1619
1620    /// Register a `defer { BLOCK }` closure to run when this scope exits.
1621    #[inline]
1622    pub fn push_defer(&mut self, coderef: StrykeValue) {
1623        if let Some(frame) = self.frames.last_mut() {
1624            frame.defers.push(coderef);
1625        }
1626    }
1627
1628    /// Take all deferred blocks from the current frame (for execution on scope exit).
1629    /// Returns them in reverse order (LIFO - last defer runs first).
1630    #[inline]
1631    pub fn take_defers(&mut self) -> Vec<StrykeValue> {
1632        if let Some(frame) = self.frames.last_mut() {
1633            let mut defers = std::mem::take(&mut frame.defers);
1634            defers.reverse();
1635            defers
1636        } else {
1637            Vec::new()
1638        }
1639    }
1640
1641    // ── Atomic array/hash declarations ──
1642    /// `declare_atomic_array` — see implementation.
1643    pub fn declare_atomic_array(&mut self, name: &str, val: Vec<StrykeValue>) {
1644        canon_main!(name);
1645        if let Some(frame) = self.frames.last_mut() {
1646            frame
1647                .atomic_arrays
1648                .push((name.to_string(), AtomicArray(Arc::new(Mutex::new(val)))));
1649        }
1650    }
1651    /// `declare_atomic_hash` — see implementation.
1652    pub fn declare_atomic_hash(&mut self, name: &str, val: IndexMap<String, StrykeValue>) {
1653        canon_main!(name);
1654        if let Some(frame) = self.frames.last_mut() {
1655            frame
1656                .atomic_hashes
1657                .push((name.to_string(), AtomicHash(Arc::new(Mutex::new(val)))));
1658        }
1659    }
1660
1661    /// Find an atomic array by name (returns the Arc for sharing).
1662    fn find_atomic_array(&self, name: &str) -> Option<&AtomicArray> {
1663        let name = strip_main_prefix(name).unwrap_or(name);
1664        for frame in self.frames.iter().rev() {
1665            if let Some(aa) = frame.atomic_arrays.iter().find(|(k, _)| k == name) {
1666                return Some(&aa.1);
1667            }
1668        }
1669        None
1670    }
1671
1672    /// Find an atomic hash by name.
1673    fn find_atomic_hash(&self, name: &str) -> Option<&AtomicHash> {
1674        let name = strip_main_prefix(name).unwrap_or(name);
1675        for frame in self.frames.iter().rev() {
1676            if let Some(ah) = frame.atomic_hashes.iter().find(|(k, _)| k == name) {
1677                return Some(&ah.1);
1678            }
1679        }
1680        None
1681    }
1682
1683    // ── Arrays ──
1684
1685    /// Remove `@_` from the innermost frame without cloning (move out of the frame `sub_underscore` field).
1686    /// Call sites restore with [`Self::declare_array`] before running a body that uses `shift` / `@_`.
1687    #[inline]
1688    pub fn take_sub_underscore(&mut self) -> Option<Vec<StrykeValue>> {
1689        self.frames.last_mut()?.sub_underscore.take()
1690    }
1691    /// `declare_array` — see implementation.
1692    pub fn declare_array(&mut self, name: &str, val: Vec<StrykeValue>) {
1693        self.declare_array_frozen(name, val, false);
1694    }
1695    /// `declare_array_frozen` — see implementation.
1696    pub fn declare_array_frozen(&mut self, name: &str, val: Vec<StrykeValue>, frozen: bool) {
1697        // Bug fix 2026-05-27: must capture is-package-qualified BEFORE
1698        // canon_main! strips the `main::` prefix. Without this check, the
1699        // post-strip `name.contains("::")` is false for `main::a` (which
1700        // becomes "a"), so `our @a` was incorrectly storing in the
1701        // innermost lexical frame instead of frame[0]. (Cross-package
1702        // names like `Foo::BAR` worked because canon_main only strips
1703        // `main::`.) Same root cause as the hash bug fixed above.
1704        let is_package_qualified = name.contains("::");
1705        canon_main!(name);
1706        // Package stash names (`Foo::BAR` / `main::name` via `our`) live in
1707        // the outermost frame so nested blocks/subs cannot shadow them and
1708        // so they persist across EVALs on a persistent VMHelper.
1709        let idx = if is_package_qualified {
1710            0
1711        } else {
1712            self.frames.len().saturating_sub(1)
1713        };
1714        if let Some(frame) = self.frames.get_mut(idx) {
1715            // Remove any existing shared Arc — re-declaration disconnects old refs.
1716            frame.shared_arrays.retain(|(k, _)| k != name);
1717            frame.set_array(name, val);
1718            if frozen {
1719                frame.frozen_arrays.insert(name.to_string());
1720            } else {
1721                // Redeclaring as non-frozen should unfreeze if previously frozen
1722                frame.frozen_arrays.remove(name);
1723            }
1724        }
1725    }
1726    /// `get_array` — see implementation.
1727    pub fn get_array(&self, name: &str) -> Vec<StrykeValue> {
1728        // `@main::X` aliases the bare `@X` because `main` is the default
1729        // package — `@main::INC` ≡ `@INC`, `@main::ARGV` ≡ `@ARGV`,
1730        // `@main::fpath` ≡ `@fpath`. The bare form is what's actually
1731        // stored, so the qualified form has to short-circuit through
1732        // the unqualified lookup.
1733        if let Some(rest) = strip_main_prefix(name) {
1734            return self.get_array(rest);
1735        }
1736        // Check atomic arrays first
1737        if let Some(aa) = self.find_atomic_array(name) {
1738            return aa.0.lock().clone();
1739        }
1740        // Check shared (Arc-backed) arrays
1741        if let Some(arc) = self.find_shared_array(name) {
1742            return arc.read().clone();
1743        }
1744        if name.contains("::") {
1745            if let Some(f) = self.frames.first() {
1746                if let Some(val) = f.get_array(name) {
1747                    return val.clone();
1748                }
1749            }
1750            return Vec::new();
1751        }
1752        for frame in self.frames.iter().rev() {
1753            if let Some(val) = frame.get_array(name) {
1754                return val.clone();
1755            }
1756        }
1757        Vec::new()
1758    }
1759
1760    /// Borrow the innermost binding for `name` when it is a plain [`Vec`] (not `mysync`).
1761    /// Used to pass `@_` to [`crate::list_builtins::native_dispatch`] without cloning the vector.
1762    #[inline]
1763    pub fn get_array_borrow(&self, name: &str) -> Option<&[StrykeValue]> {
1764        if let Some(rest) = strip_main_prefix(name) {
1765            return self.get_array_borrow(rest);
1766        }
1767        if self.find_atomic_array(name).is_some() {
1768            return None;
1769        }
1770        if name.contains("::") {
1771            return self
1772                .frames
1773                .first()
1774                .and_then(|f| f.get_array(name))
1775                .map(|v| v.as_slice());
1776        }
1777        for frame in self.frames.iter().rev() {
1778            if let Some(val) = frame.get_array(name) {
1779                return Some(val.as_slice());
1780            }
1781        }
1782        None
1783    }
1784
1785    fn resolve_array_frame_idx(&self, name: &str) -> Option<usize> {
1786        if name.contains("::") {
1787            return Some(0);
1788        }
1789        (0..self.frames.len())
1790            .rev()
1791            .find(|&i| self.frames[i].has_array(name))
1792    }
1793
1794    fn check_parallel_array_write(&self, name: &str) -> Result<(), StrykeError> {
1795        if !self.parallel_guard
1796            || Self::parallel_skip_special_name(name)
1797            || Self::parallel_allowed_internal_array(name)
1798        {
1799            return Ok(());
1800        }
1801        // Worker-local frames are at depth >= baseline.
1802        let baseline = self.parallel_guard_baseline;
1803        match self.resolve_array_frame_idx(name) {
1804            None => Err(StrykeError::runtime(
1805                format!(
1806                    "cannot modify undeclared array `@{}` in a parallel block",
1807                    name
1808                ),
1809                0,
1810            )),
1811            Some(idx) if idx < baseline => Err(StrykeError::runtime(
1812                format!(
1813                    "cannot modify captured non-mysync array `@{}` in a parallel block",
1814                    name
1815                ),
1816                0,
1817            )),
1818            Some(_) => Ok(()),
1819        }
1820    }
1821
1822    /// Resolve an [`ArrayBindingRef`] or [`HashBindingRef`] to an Arc-backed
1823    /// snapshot so the value survives scope pop. Called when a value is stored
1824    /// as an *element* inside a container (array/hash) — NOT for scalar assignment,
1825    /// where binding refs must stay live for aliasing.
1826    #[inline]
1827    pub fn resolve_container_binding_ref(&self, val: StrykeValue) -> StrykeValue {
1828        if let Some(name) = val.as_array_binding_name() {
1829            let data = self.get_array(&name);
1830            return StrykeValue::array_ref(Arc::new(parking_lot::RwLock::new(data)));
1831        }
1832        if let Some(name) = val.as_hash_binding_name() {
1833            let data = self.get_hash(&name);
1834            return StrykeValue::hash_ref(Arc::new(parking_lot::RwLock::new(data)));
1835        }
1836        val
1837    }
1838
1839    /// Promote `@name` to shared Arc-backed storage and return an [`ArrayRef`] that
1840    /// shares the same `Arc`. Both the scope binding and the returned ref point to
1841    /// the same data, so mutations through either path are visible.
1842    pub fn promote_array_to_shared(
1843        &mut self,
1844        name: &str,
1845    ) -> Arc<parking_lot::RwLock<Vec<StrykeValue>>> {
1846        // Bug fix 2026-05-27: same `main::` prefix-stripping bug that bit
1847        // promote_hash_to_shared. Without canonicalization, `\@main::a` (or
1848        // `\@a` when the compiler attaches the `main::` qualifier for
1849        // package globals) would look up the literal `"main::a"` key in
1850        // `frame.arrays`, fail to find the canonically-stored `"a"` entry,
1851        // and return an empty Arc — breaking every reference take on an
1852        // `our`-declared array.
1853        let name = strip_main_prefix(name).unwrap_or(name);
1854
1855        // Atomic (mysync) arrays: snapshot current data into a separate Arc.
1856        // Can't share the Mutex-backed storage directly.
1857        if let Some(aa) = self.find_atomic_array(name) {
1858            let data = aa.0.lock().clone();
1859            return Arc::new(parking_lot::RwLock::new(data));
1860        }
1861        // Already promoted? Return the existing Arc.
1862        let idx = self.resolve_array_frame_idx(name).unwrap_or_default();
1863        let frame = &mut self.frames[idx];
1864        if let Some(entry) = frame.shared_arrays.iter().find(|(k, _)| k == name) {
1865            return Arc::clone(&entry.1);
1866        }
1867        // Take data from frame.arrays, create Arc, store in shared_arrays.
1868        let data = if let Some(pos) = frame.arrays.iter().position(|(k, _)| k == name) {
1869            frame.arrays.swap_remove(pos).1
1870        } else if name == "_" {
1871            frame.sub_underscore.take().unwrap_or_default()
1872        } else {
1873            Vec::new()
1874        };
1875        let arc = Arc::new(parking_lot::RwLock::new(data));
1876        frame
1877            .shared_arrays
1878            .push((name.to_string(), Arc::clone(&arc)));
1879        arc
1880    }
1881
1882    /// Promote `%name` to shared Arc-backed storage and return a [`HashRef`] that
1883    /// shares the same `Arc`.
1884    pub fn promote_hash_to_shared(
1885        &mut self,
1886        name: &str,
1887    ) -> Arc<parking_lot::RwLock<IndexMap<String, StrykeValue>>> {
1888        // Bug fix 2026-05-27: this function used to compare the raw `name`
1889        // arg against `frame.hashes`/`shared_hashes` entry keys. But entries
1890        // are stored canonically (the `main::` prefix already stripped via
1891        // `canon_main!` in `set_hash`/`declare_hash`/etc), so a call like
1892        // `promote_hash_to_shared("main::h")` would fail to find the
1893        // existing `"h"` entry and return an empty Arc — breaking every
1894        // `\%h` / `\%main::h` reference take in Tier 3 lick/peruse paths.
1895        // Canonicalize at entry so the find/position/push all use the
1896        // same key as the rest of the scope machinery.
1897        let name = strip_main_prefix(name).unwrap_or(name);
1898
1899        let idx = self.resolve_hash_frame_idx(name).unwrap_or_default();
1900        let frame = &mut self.frames[idx];
1901        if let Some(entry) = frame.shared_hashes.iter().find(|(k, _)| k == name) {
1902            return Arc::clone(&entry.1);
1903        }
1904        let data = if let Some(pos) = frame.hashes.iter().position(|(k, _)| k == name) {
1905            frame.hashes.swap_remove(pos).1
1906        } else {
1907            IndexMap::new()
1908        };
1909        let arc = Arc::new(parking_lot::RwLock::new(data));
1910        frame
1911            .shared_hashes
1912            .push((name.to_string(), Arc::clone(&arc)));
1913        arc
1914    }
1915
1916    /// Find the shared Arc for `@name`, if any.
1917    fn find_shared_array(&self, name: &str) -> Option<Arc<parking_lot::RwLock<Vec<StrykeValue>>>> {
1918        let name = strip_main_prefix(name).unwrap_or(name);
1919        for frame in self.frames.iter().rev() {
1920            if let Some(entry) = frame.shared_arrays.iter().find(|(k, _)| k == name) {
1921                return Some(Arc::clone(&entry.1));
1922            }
1923            // If this frame has the plain array, stop — it shadows outer shared ones.
1924            if frame.arrays.iter().any(|(k, _)| k == name) {
1925                return None;
1926            }
1927        }
1928        None
1929    }
1930
1931    /// Find the shared Arc for `%name`, if any.
1932    fn find_shared_hash(
1933        &self,
1934        name: &str,
1935    ) -> Option<Arc<parking_lot::RwLock<IndexMap<String, StrykeValue>>>> {
1936        let name = strip_main_prefix(name).unwrap_or(name);
1937        for frame in self.frames.iter().rev() {
1938            if let Some(entry) = frame.shared_hashes.iter().find(|(k, _)| k == name) {
1939                return Some(Arc::clone(&entry.1));
1940            }
1941            if frame.hashes.iter().any(|(k, _)| k == name) {
1942                return None;
1943            }
1944        }
1945        None
1946    }
1947    /// `get_array_mut` — see implementation.
1948    pub fn get_array_mut(&mut self, name: &str) -> Result<&mut Vec<StrykeValue>, StrykeError> {
1949        // `@main::a` ≡ `@a`: canonicalize to the bare key so the autoviv push below uses the
1950        // same key the frame getter looks up by (which strips `main::`). Without this, a
1951        // qualified `@main::a` pushes a `"main::a"` entry that the re-lookup — canonicalized to
1952        // `"a"` — never finds, panicking on `unwrap()`.
1953        canon_main!(name);
1954        // Note: can't return &mut into a Mutex. Callers needing atomic array
1955        // mutation should use atomic_array_mutate instead. For non-atomic arrays:
1956        if self.find_atomic_array(name).is_some() {
1957            return Err(StrykeError::runtime(
1958                "get_array_mut: use atomic path for mysync arrays",
1959                0,
1960            ));
1961        }
1962        self.check_parallel_array_write(name)?;
1963        let idx = self.resolve_array_frame_idx(name).unwrap_or_default();
1964        let frame = &mut self.frames[idx];
1965        if frame.get_array_mut(name).is_none() {
1966            frame.arrays.push((name.to_string(), Vec::new()));
1967        }
1968        Ok(frame.get_array_mut(name).unwrap())
1969    }
1970
1971    /// Push to array — works for both regular and atomic arrays.
1972    pub fn push_to_array(&mut self, name: &str, val: StrykeValue) -> Result<(), StrykeError> {
1973        let val = self.resolve_container_binding_ref(val);
1974        if let Some(aa) = self.find_atomic_array(name) {
1975            aa.0.lock().push(val);
1976            return Ok(());
1977        }
1978        if let Some(arc) = self.find_shared_array(name) {
1979            arc.write().push(val);
1980            return Ok(());
1981        }
1982        self.get_array_mut(name)?.push(val);
1983        Ok(())
1984    }
1985
1986    /// Bulk `push @name, start..end-1` for the fused counted-loop superinstruction:
1987    /// reserves the `Vec` once, then pushes `StrykeValue::integer(i)` for `i in start..end`
1988    /// in a tight Rust loop. Atomic arrays take a single `lock().push()` burst.
1989    pub fn push_int_range_to_array(
1990        &mut self,
1991        name: &str,
1992        start: i64,
1993        end: i64,
1994    ) -> Result<(), StrykeError> {
1995        if end <= start {
1996            return Ok(());
1997        }
1998        let count = (end - start) as usize;
1999        if let Some(aa) = self.find_atomic_array(name) {
2000            let mut g = aa.0.lock();
2001            g.reserve(count);
2002            for i in start..end {
2003                g.push(StrykeValue::integer(i));
2004            }
2005            return Ok(());
2006        }
2007        let arr = self.get_array_mut(name)?;
2008        arr.reserve(count);
2009        for i in start..end {
2010            arr.push(StrykeValue::integer(i));
2011        }
2012        Ok(())
2013    }
2014
2015    /// Pop from array — works for regular, shared, and atomic arrays.
2016    pub fn pop_from_array(&mut self, name: &str) -> Result<StrykeValue, StrykeError> {
2017        if let Some(aa) = self.find_atomic_array(name) {
2018            return Ok(aa.0.lock().pop().unwrap_or(StrykeValue::UNDEF));
2019        }
2020        if let Some(arc) = self.find_shared_array(name) {
2021            return Ok(arc.write().pop().unwrap_or(StrykeValue::UNDEF));
2022        }
2023        Ok(self
2024            .get_array_mut(name)?
2025            .pop()
2026            .unwrap_or(StrykeValue::UNDEF))
2027    }
2028
2029    /// Shift from array — works for regular, shared, and atomic arrays.
2030    pub fn shift_from_array(&mut self, name: &str) -> Result<StrykeValue, StrykeError> {
2031        if let Some(aa) = self.find_atomic_array(name) {
2032            let mut guard = aa.0.lock();
2033            return Ok(if guard.is_empty() {
2034                StrykeValue::UNDEF
2035            } else {
2036                guard.remove(0)
2037            });
2038        }
2039        if let Some(arc) = self.find_shared_array(name) {
2040            let mut arr = arc.write();
2041            return Ok(if arr.is_empty() {
2042                StrykeValue::UNDEF
2043            } else {
2044                arr.remove(0)
2045            });
2046        }
2047        let arr = self.get_array_mut(name)?;
2048        Ok(if arr.is_empty() {
2049            StrykeValue::UNDEF
2050        } else {
2051            arr.remove(0)
2052        })
2053    }
2054
2055    /// Splice in place — works for regular, shared, and atomic arrays.
2056    /// `off..end` must already be clamped (use `splice_compute_range` to compute).
2057    /// Returns the removed elements.
2058    pub fn splice_in_place(
2059        &mut self,
2060        name: &str,
2061        off: usize,
2062        end: usize,
2063        rep_vals: Vec<StrykeValue>,
2064    ) -> Result<Vec<StrykeValue>, StrykeError> {
2065        if let Some(aa) = self.find_atomic_array(name) {
2066            let mut g = aa.0.lock();
2067            let removed: Vec<StrykeValue> = g.drain(off..end).collect();
2068            for (i, v) in rep_vals.into_iter().enumerate() {
2069                g.insert(off + i, v);
2070            }
2071            return Ok(removed);
2072        }
2073        if let Some(arc) = self.find_shared_array(name) {
2074            let mut g = arc.write();
2075            let removed: Vec<StrykeValue> = g.drain(off..end).collect();
2076            for (i, v) in rep_vals.into_iter().enumerate() {
2077                g.insert(off + i, v);
2078            }
2079            return Ok(removed);
2080        }
2081        let arr = self.get_array_mut(name)?;
2082        let removed: Vec<StrykeValue> = arr.drain(off..end).collect();
2083        for (i, v) in rep_vals.into_iter().enumerate() {
2084            arr.insert(off + i, v);
2085        }
2086        Ok(removed)
2087    }
2088
2089    /// Get array length — works for both regular and atomic arrays.
2090    pub fn array_len(&self, name: &str) -> usize {
2091        canon_main!(name);
2092        if let Some(aa) = self.find_atomic_array(name) {
2093            return aa.0.lock().len();
2094        }
2095        if let Some(arc) = self.find_shared_array(name) {
2096            return arc.read().len();
2097        }
2098        if name.contains("::") {
2099            return self
2100                .frames
2101                .first()
2102                .and_then(|f| f.get_array(name))
2103                .map(|a| a.len())
2104                .unwrap_or(0);
2105        }
2106        for frame in self.frames.iter().rev() {
2107            if let Some(arr) = frame.get_array(name) {
2108                return arr.len();
2109            }
2110        }
2111        0
2112    }
2113    /// `set_array` — see implementation.
2114    pub fn set_array(&mut self, name: &str, val: Vec<StrykeValue>) -> Result<(), StrykeError> {
2115        if let Some(aa) = self.find_atomic_array(name) {
2116            *aa.0.lock() = val;
2117            return Ok(());
2118        }
2119        if let Some(arc) = self.find_shared_array(name) {
2120            *arc.write() = val;
2121            return Ok(());
2122        }
2123        self.check_parallel_array_write(name)?;
2124        for frame in self.frames.iter_mut().rev() {
2125            if frame.has_array(name) {
2126                frame.set_array(name, val);
2127                return Ok(());
2128            }
2129        }
2130        self.frames[0].set_array(name, val);
2131        Ok(())
2132    }
2133
2134    /// Direct element access — works for both regular and atomic arrays.
2135    #[inline]
2136    pub fn get_array_element(&self, name: &str, index: i64) -> StrykeValue {
2137        canon_main!(name);
2138        if let Some(aa) = self.find_atomic_array(name) {
2139            let arr = aa.0.lock();
2140            let idx = if index < 0 {
2141                (arr.len() as i64 + index) as usize
2142            } else {
2143                index as usize
2144            };
2145            return arr.get(idx).cloned().unwrap_or(StrykeValue::UNDEF);
2146        }
2147        if let Some(arc) = self.find_shared_array(name) {
2148            let arr = arc.read();
2149            let idx = if index < 0 {
2150                (arr.len() as i64 + index) as usize
2151            } else {
2152                index as usize
2153            };
2154            return arr.get(idx).cloned().unwrap_or(StrykeValue::UNDEF);
2155        }
2156        for frame in self.frames.iter().rev() {
2157            if let Some(arr) = frame.get_array(name) {
2158                let idx = if index < 0 {
2159                    (arr.len() as i64 + index) as usize
2160                } else {
2161                    index as usize
2162                };
2163                return arr.get(idx).cloned().unwrap_or(StrykeValue::UNDEF);
2164            }
2165        }
2166        StrykeValue::UNDEF
2167    }
2168    /// `set_array_element` — see implementation.
2169    pub fn set_array_element(
2170        &mut self,
2171        name: &str,
2172        index: i64,
2173        val: StrykeValue,
2174    ) -> Result<(), StrykeError> {
2175        let val = self.resolve_container_binding_ref(val);
2176        if let Some(aa) = self.find_atomic_array(name) {
2177            let mut arr = aa.0.lock();
2178            let idx = if index < 0 {
2179                (arr.len() as i64 + index).max(0) as usize
2180            } else {
2181                index as usize
2182            };
2183            if idx >= arr.len() {
2184                arr.resize(idx + 1, StrykeValue::UNDEF);
2185            }
2186            arr[idx] = val;
2187            return Ok(());
2188        }
2189        if let Some(arc) = self.find_shared_array(name) {
2190            let mut arr = arc.write();
2191            let idx = if index < 0 {
2192                (arr.len() as i64 + index).max(0) as usize
2193            } else {
2194                index as usize
2195            };
2196            if idx >= arr.len() {
2197                arr.resize(idx + 1, StrykeValue::UNDEF);
2198            }
2199            arr[idx] = val;
2200            return Ok(());
2201        }
2202        let arr = self.get_array_mut(name)?;
2203        let idx = if index < 0 {
2204            let len = arr.len() as i64;
2205            (len + index).max(0) as usize
2206        } else {
2207            index as usize
2208        };
2209        if idx >= arr.len() {
2210            arr.resize(idx + 1, StrykeValue::UNDEF);
2211        }
2212        arr[idx] = val;
2213        Ok(())
2214    }
2215
2216    /// Perl `exists $a[$i]` — true when the slot index is within the current array length.
2217    pub fn exists_array_element(&self, name: &str, index: i64) -> bool {
2218        canon_main!(name);
2219        if let Some(aa) = self.find_atomic_array(name) {
2220            let arr = aa.0.lock();
2221            let idx = if index < 0 {
2222                (arr.len() as i64 + index) as usize
2223            } else {
2224                index as usize
2225            };
2226            return idx < arr.len();
2227        }
2228        for frame in self.frames.iter().rev() {
2229            if let Some(arr) = frame.get_array(name) {
2230                let idx = if index < 0 {
2231                    (arr.len() as i64 + index) as usize
2232                } else {
2233                    index as usize
2234                };
2235                return idx < arr.len();
2236            }
2237        }
2238        false
2239    }
2240
2241    /// Perl `delete $a[$i]` — sets the element to `undef`, returns the previous value.
2242    pub fn delete_array_element(
2243        &mut self,
2244        name: &str,
2245        index: i64,
2246    ) -> Result<StrykeValue, StrykeError> {
2247        if let Some(aa) = self.find_atomic_array(name) {
2248            let mut arr = aa.0.lock();
2249            let idx = if index < 0 {
2250                (arr.len() as i64 + index) as usize
2251            } else {
2252                index as usize
2253            };
2254            if idx >= arr.len() {
2255                return Ok(StrykeValue::UNDEF);
2256            }
2257            let old = arr.get(idx).cloned().unwrap_or(StrykeValue::UNDEF);
2258            arr[idx] = StrykeValue::UNDEF;
2259            return Ok(old);
2260        }
2261        let arr = self.get_array_mut(name)?;
2262        let idx = if index < 0 {
2263            (arr.len() as i64 + index) as usize
2264        } else {
2265            index as usize
2266        };
2267        if idx >= arr.len() {
2268            return Ok(StrykeValue::UNDEF);
2269        }
2270        let old = arr.get(idx).cloned().unwrap_or(StrykeValue::UNDEF);
2271        arr[idx] = StrykeValue::UNDEF;
2272        Ok(old)
2273    }
2274
2275    // ── Hashes ──
2276    /// `declare_hash` — see implementation.
2277    #[inline]
2278    pub fn declare_hash(&mut self, name: &str, val: IndexMap<String, StrykeValue>) {
2279        self.declare_hash_frozen(name, val, false);
2280    }
2281    /// `declare_hash_frozen` — see implementation.
2282    pub fn declare_hash_frozen(
2283        &mut self,
2284        name: &str,
2285        val: IndexMap<String, StrykeValue>,
2286        frozen: bool,
2287    ) {
2288        let is_package_qualified = name.contains("::");
2289        canon_main!(name);
2290        let frame_opt = if is_package_qualified {
2291            self.frames.first_mut()
2292        } else {
2293            self.frames.last_mut()
2294        };
2295        if let Some(frame) = frame_opt {
2296            // Remove any existing shared Arc — re-declaration disconnects old refs.
2297            frame.shared_hashes.retain(|(k, _)| k != name);
2298            frame.set_hash(name, val);
2299            if frozen {
2300                frame.frozen_hashes.insert(name.to_string());
2301            } else {
2302                // Re-declaring as non-frozen unfreezes — matches the array path.
2303                frame.frozen_hashes.remove(name);
2304            }
2305        }
2306    }
2307
2308    /// Declare a hash in the bottom (global) frame, not the current lexical frame.
2309    pub fn declare_hash_global(&mut self, name: &str, val: IndexMap<String, StrykeValue>) {
2310        canon_main!(name);
2311        if let Some(frame) = self.frames.first_mut() {
2312            frame.set_hash(name, val);
2313        }
2314    }
2315
2316    /// Declare a frozen hash in the bottom (global) frame — prevents user reassignment.
2317    pub fn declare_hash_global_frozen(&mut self, name: &str, val: IndexMap<String, StrykeValue>) {
2318        canon_main!(name);
2319        if let Some(frame) = self.frames.first_mut() {
2320            frame.set_hash(name, val);
2321            frame.frozen_hashes.insert(name.to_string());
2322        }
2323    }
2324
2325    /// Returns `true` if a lexical (non-bottom) frame declares `%name`.
2326    pub fn has_lexical_hash(&self, name: &str) -> bool {
2327        canon_main!(name);
2328        self.frames.iter().skip(1).any(|f| f.has_hash(name))
2329    }
2330
2331    /// Returns `true` if ANY frame (including global) declares `%name`.
2332    pub fn any_frame_has_hash(&self, name: &str) -> bool {
2333        canon_main!(name);
2334        self.frames.iter().any(|f| f.has_hash(name))
2335    }
2336    /// `get_hash` — see implementation.
2337    pub fn get_hash(&self, name: &str) -> IndexMap<String, StrykeValue> {
2338        // `%main::X` aliases the bare `%X` (default-package equivalence).
2339        if let Some(rest) = strip_main_prefix(name) {
2340            return self.get_hash(rest);
2341        }
2342        if let Some(ah) = self.find_atomic_hash(name) {
2343            return ah.0.lock().clone();
2344        }
2345        if let Some(arc) = self.find_shared_hash(name) {
2346            return arc.read().clone();
2347        }
2348        for frame in self.frames.iter().rev() {
2349            if let Some(val) = frame.get_hash(name) {
2350                return val.clone();
2351            }
2352        }
2353        IndexMap::new()
2354    }
2355
2356    fn resolve_hash_frame_idx(&self, name: &str) -> Option<usize> {
2357        if name.contains("::") {
2358            return Some(0);
2359        }
2360        (0..self.frames.len())
2361            .rev()
2362            .find(|&i| self.frames[i].has_hash(name))
2363    }
2364
2365    fn check_parallel_hash_write(&self, name: &str) -> Result<(), StrykeError> {
2366        if !self.parallel_guard
2367            || Self::parallel_skip_special_name(name)
2368            || Self::parallel_allowed_internal_hash(name)
2369        {
2370            return Ok(());
2371        }
2372        // Worker-local frames are at depth >= baseline.
2373        let baseline = self.parallel_guard_baseline;
2374        match self.resolve_hash_frame_idx(name) {
2375            None => Err(StrykeError::runtime(
2376                format!(
2377                    "cannot modify undeclared hash `%{}` in a parallel block",
2378                    name
2379                ),
2380                0,
2381            )),
2382            Some(idx) if idx < baseline => Err(StrykeError::runtime(
2383                format!(
2384                    "cannot modify captured non-mysync hash `%{}` in a parallel block",
2385                    name
2386                ),
2387                0,
2388            )),
2389            Some(_) => Ok(()),
2390        }
2391    }
2392    /// `get_hash_mut` — see implementation.
2393    pub fn get_hash_mut(
2394        &mut self,
2395        name: &str,
2396    ) -> Result<&mut IndexMap<String, StrykeValue>, StrykeError> {
2397        // `%main::h` ≡ `%h`: canonicalize so the autoviv push uses the same key the frame
2398        // getter looks up by. Mirrors `get_array_mut` — see the note there.
2399        canon_main!(name);
2400        if self.find_atomic_hash(name).is_some() {
2401            return Err(StrykeError::runtime(
2402                "get_hash_mut: use atomic path for mysync hashes",
2403                0,
2404            ));
2405        }
2406        self.check_parallel_hash_write(name)?;
2407        let idx = self.resolve_hash_frame_idx(name).unwrap_or_default();
2408        let frame = &mut self.frames[idx];
2409        if frame.get_hash_mut(name).is_none() {
2410            frame.hashes.push((name.to_string(), IndexMap::new()));
2411        }
2412        Ok(frame.get_hash_mut(name).unwrap())
2413    }
2414    /// `set_hash` — see implementation.
2415    pub fn set_hash(
2416        &mut self,
2417        name: &str,
2418        val: IndexMap<String, StrykeValue>,
2419    ) -> Result<(), StrykeError> {
2420        if let Some(ah) = self.find_atomic_hash(name) {
2421            *ah.0.lock() = val;
2422            return Ok(());
2423        }
2424        self.check_parallel_hash_write(name)?;
2425        for frame in self.frames.iter_mut().rev() {
2426            if frame.has_hash(name) {
2427                frame.set_hash(name, val);
2428                return Ok(());
2429            }
2430        }
2431        self.frames[0].set_hash(name, val);
2432        Ok(())
2433    }
2434    /// `get_hash_element` — see implementation.
2435    #[inline]
2436    pub fn get_hash_element(&self, name: &str, key: &str) -> StrykeValue {
2437        canon_main!(name);
2438        if let Some(ah) = self.find_atomic_hash(name) {
2439            return ah.0.lock().get(key).cloned().unwrap_or(StrykeValue::UNDEF);
2440        }
2441        if let Some(arc) = self.find_shared_hash(name) {
2442            return arc.read().get(key).cloned().unwrap_or(StrykeValue::UNDEF);
2443        }
2444        for frame in self.frames.iter().rev() {
2445            if let Some(hash) = frame.get_hash(name) {
2446                return hash.get(key).cloned().unwrap_or(StrykeValue::UNDEF);
2447            }
2448        }
2449        StrykeValue::UNDEF
2450    }
2451
2452    /// Atomically read-modify-write a hash element. For atomic hashes, holds
2453    /// the Mutex for the full cycle. Returns the new value.
2454    pub fn atomic_hash_mutate(
2455        &mut self,
2456        name: &str,
2457        key: &str,
2458        f: impl FnOnce(&StrykeValue) -> StrykeValue,
2459    ) -> Result<StrykeValue, StrykeError> {
2460        if let Some(ah) = self.find_atomic_hash(name) {
2461            let mut guard = ah.0.lock();
2462            let old = guard.get(key).cloned().unwrap_or(StrykeValue::UNDEF);
2463            let new_val = f(&old);
2464            guard.insert(key.to_string(), new_val.clone());
2465            return Ok(new_val);
2466        }
2467        // Non-atomic fallback
2468        let old = self.get_hash_element(name, key);
2469        let new_val = f(&old);
2470        self.set_hash_element(name, key, new_val.clone())?;
2471        Ok(new_val)
2472    }
2473
2474    /// Atomically read-modify-write an array element. Returns the new value.
2475    pub fn atomic_array_mutate(
2476        &mut self,
2477        name: &str,
2478        index: i64,
2479        f: impl FnOnce(&StrykeValue) -> StrykeValue,
2480    ) -> Result<StrykeValue, StrykeError> {
2481        if let Some(aa) = self.find_atomic_array(name) {
2482            let mut guard = aa.0.lock();
2483            let idx = if index < 0 {
2484                (guard.len() as i64 + index).max(0) as usize
2485            } else {
2486                index as usize
2487            };
2488            if idx >= guard.len() {
2489                guard.resize(idx + 1, StrykeValue::UNDEF);
2490            }
2491            let old = guard[idx].clone();
2492            let new_val = f(&old);
2493            guard[idx] = new_val.clone();
2494            return Ok(new_val);
2495        }
2496        // Non-atomic fallback
2497        let old = self.get_array_element(name, index);
2498        let new_val = f(&old);
2499        self.set_array_element(name, index, new_val.clone())?;
2500        Ok(new_val)
2501    }
2502    /// `set_hash_element` — see implementation.
2503    pub fn set_hash_element(
2504        &mut self,
2505        name: &str,
2506        key: &str,
2507        val: StrykeValue,
2508    ) -> Result<(), StrykeError> {
2509        let val = self.resolve_container_binding_ref(val);
2510        // `$SIG{INT} = \&h` — lazily install the matching signal hook. Until Perl code touches
2511        // `%SIG`, the POSIX default stays in place so Ctrl-C terminates immediately.
2512        if name == "SIG" {
2513            crate::perl_signal::install(key);
2514        }
2515        // `$ENV{KEY} = VALUE` — Perl propagates writes through `%ENV` into the
2516        // real process environment so child processes inherit them. Mirror
2517        // that here; stringify the value the same way `system` reads other
2518        // scalars.
2519        if name == "ENV" {
2520            // SAFETY: `set_var` is `unsafe` in newer Rust due to multi-thread
2521            // env race concerns. Stryke writes `%ENV` from the main interpreter
2522            // thread and matches Perl 5's documented semantics.
2523            std::env::set_var(key, val.to_string());
2524        }
2525        if let Some(ah) = self.find_atomic_hash(name) {
2526            ah.0.lock().insert(key.to_string(), val);
2527            return Ok(());
2528        }
2529        if let Some(arc) = self.find_shared_hash(name) {
2530            arc.write().insert(key.to_string(), val);
2531            return Ok(());
2532        }
2533        let hash = self.get_hash_mut(name)?;
2534        hash.insert(key.to_string(), val);
2535        Ok(())
2536    }
2537
2538    /// Bulk `for i in start..end { $h{i} = i * k }` for the fused hash-insert loop.
2539    /// Reserves capacity once and runs the whole range in a tight Rust loop.
2540    /// `itoa` is used to stringify each key without a transient `format!` allocation.
2541    pub fn set_hash_int_times_range(
2542        &mut self,
2543        name: &str,
2544        start: i64,
2545        end: i64,
2546        k: i64,
2547    ) -> Result<(), StrykeError> {
2548        if end <= start {
2549            return Ok(());
2550        }
2551        let count = (end - start) as usize;
2552        if let Some(ah) = self.find_atomic_hash(name) {
2553            let mut g = ah.0.lock();
2554            g.reserve(count);
2555            let mut buf = itoa::Buffer::new();
2556            for i in start..end {
2557                let key = buf.format(i).to_owned();
2558                g.insert(key, StrykeValue::integer(i.wrapping_mul(k)));
2559            }
2560            return Ok(());
2561        }
2562        let hash = self.get_hash_mut(name)?;
2563        hash.reserve(count);
2564        let mut buf = itoa::Buffer::new();
2565        for i in start..end {
2566            let key = buf.format(i).to_owned();
2567            hash.insert(key, StrykeValue::integer(i.wrapping_mul(k)));
2568        }
2569        Ok(())
2570    }
2571    /// `delete_hash_element` — see implementation.
2572    pub fn delete_hash_element(
2573        &mut self,
2574        name: &str,
2575        key: &str,
2576    ) -> Result<StrykeValue, StrykeError> {
2577        canon_main!(name);
2578        // `delete $ENV{KEY}` — match Perl by unsetting the real process env.
2579        if name == "ENV" {
2580            std::env::remove_var(key);
2581        }
2582        if let Some(ah) = self.find_atomic_hash(name) {
2583            return Ok(ah.0.lock().shift_remove(key).unwrap_or(StrykeValue::UNDEF));
2584        }
2585        let hash = self.get_hash_mut(name)?;
2586        Ok(hash.shift_remove(key).unwrap_or(StrykeValue::UNDEF))
2587    }
2588    /// `exists_hash_element` — see implementation.
2589    #[inline]
2590    pub fn exists_hash_element(&self, name: &str, key: &str) -> bool {
2591        canon_main!(name);
2592        if let Some(ah) = self.find_atomic_hash(name) {
2593            return ah.0.lock().contains_key(key);
2594        }
2595        for frame in self.frames.iter().rev() {
2596            if let Some(hash) = frame.get_hash(name) {
2597                return hash.contains_key(key);
2598            }
2599        }
2600        false
2601    }
2602
2603    /// Walk all values of the named hash with a visitor. Used by the fused
2604    /// `for my $k (keys %h) { $sum += $h{$k} }` op so the hot loop runs without
2605    /// cloning the entire map into a keys array (vs the un-fused shape, which
2606    /// allocates one `StrykeValue::string` per key).
2607    #[inline]
2608    pub fn for_each_hash_value(&self, name: &str, mut visit: impl FnMut(&StrykeValue)) {
2609        canon_main!(name);
2610        if let Some(ah) = self.find_atomic_hash(name) {
2611            let g = ah.0.lock();
2612            for v in g.values() {
2613                visit(v);
2614            }
2615            return;
2616        }
2617        for frame in self.frames.iter().rev() {
2618            if let Some(hash) = frame.get_hash(name) {
2619                for v in hash.values() {
2620                    visit(v);
2621                }
2622                return;
2623            }
2624        }
2625    }
2626
2627    /// Per-frame view of binding *names* (not values) for introspection
2628    /// pipelines that need to walk every name in every frame without
2629    /// reaching into private fields. Returns `(scalars, arrays, hashes)`.
2630    /// Atomic / shared variants are folded into the matching kind so the
2631    /// caller doesn't need to know the storage form.
2632    pub fn frames_for_introspection(&self) -> Vec<(Vec<&str>, Vec<&str>, Vec<&str>)> {
2633        self.frames
2634            .iter()
2635            .map(|f| {
2636                let mut scalars: Vec<&str> = f.scalars.iter().map(|(n, _)| n.as_str()).collect();
2637                // `my $x` ends up in scalar_slots; names live alongside.
2638                scalars.extend(f.scalar_slot_names.iter().filter_map(|opt| match opt {
2639                    Some(n) if !n.is_empty() => Some(n.as_str()),
2640                    _ => None,
2641                }));
2642                let mut arrays: Vec<&str> = f.arrays.iter().map(|(n, _)| n.as_str()).collect();
2643                arrays.extend(f.atomic_arrays.iter().map(|(n, _)| n.as_str()));
2644                arrays.extend(f.shared_arrays.iter().map(|(n, _)| n.as_str()));
2645                let mut hashes: Vec<&str> = f.hashes.iter().map(|(n, _)| n.as_str()).collect();
2646                hashes.extend(f.atomic_hashes.iter().map(|(n, _)| n.as_str()));
2647                hashes.extend(f.shared_hashes.iter().map(|(n, _)| n.as_str()));
2648                scalars.sort_unstable();
2649                arrays.sort_unstable();
2650                hashes.sort_unstable();
2651                (scalars, arrays, hashes)
2652            })
2653            .collect()
2654    }
2655
2656    /// Sigil-prefixed name → variable-class string (`"scalar"`, `"array"`,
2657    /// `"hash"`, `"atomic_array"`, `"atomic_hash"`, `"shared_array"`,
2658    /// `"shared_hash"`) for every binding in every frame. Backs the
2659    /// `parameters()` builtin (zsh-`$parameters` analogue). Walks frames
2660    /// outermost → innermost so an inner shadow wins on duplicate names.
2661    pub fn parameters_pairs(&self) -> Vec<(String, &'static str)> {
2662        let mut seen: HashSet<String> = HashSet::new();
2663        let mut out: Vec<(String, &'static str)> = Vec::new();
2664        // Iterate innermost first so the closest shadow registers first;
2665        // `seen` then suppresses outer duplicates.
2666        for frame in self.frames.iter().rev() {
2667            // Slot-allocated lexical scalars (`my $x` lands here). Names live
2668            // in `scalar_slot_names`; empty / None entries are anonymous
2669            // padding slots and skipped.
2670            for n in frame.scalar_slot_names.iter().flatten() {
2671                if !n.is_empty() {
2672                    let s = format!("${}", n);
2673                    if seen.insert(s.clone()) {
2674                        out.push((s, "scalar"));
2675                    }
2676                }
2677            }
2678            for (name, _) in &frame.scalars {
2679                let s = format!("${}", name);
2680                if seen.insert(s.clone()) {
2681                    out.push((s, "scalar"));
2682                }
2683            }
2684            for (name, _) in &frame.arrays {
2685                let s = format!("@{}", name);
2686                if seen.insert(s.clone()) {
2687                    out.push((s, "array"));
2688                }
2689            }
2690            for (name, _) in &frame.hashes {
2691                let s = format!("%{}", name);
2692                if seen.insert(s.clone()) {
2693                    out.push((s, "hash"));
2694                }
2695            }
2696            for (name, _) in &frame.atomic_arrays {
2697                let s = format!("@{}", name);
2698                if seen.insert(s.clone()) {
2699                    out.push((s, "atomic_array"));
2700                }
2701            }
2702            for (name, _) in &frame.atomic_hashes {
2703                let s = format!("%{}", name);
2704                if seen.insert(s.clone()) {
2705                    out.push((s, "atomic_hash"));
2706                }
2707            }
2708            for (name, _) in &frame.shared_arrays {
2709                let s = format!("@{}", name);
2710                if seen.insert(s.clone()) {
2711                    out.push((s, "shared_array"));
2712                }
2713            }
2714            for (name, _) in &frame.shared_hashes {
2715                let s = format!("%{}", name);
2716                if seen.insert(s.clone()) {
2717                    out.push((s, "shared_hash"));
2718                }
2719            }
2720        }
2721        out.sort_by(|a, b| a.0.cmp(&b.0));
2722        out
2723    }
2724
2725    /// Sigil-prefixed names (`$x`, `@a`, `%h`) from all frames, for REPL tab-completion.
2726    pub fn repl_binding_names(&self) -> Vec<String> {
2727        let mut seen = HashSet::new();
2728        let mut out = Vec::new();
2729        for frame in &self.frames {
2730            for (name, _) in &frame.scalars {
2731                let s = format!("${}", name);
2732                if seen.insert(s.clone()) {
2733                    out.push(s);
2734                }
2735            }
2736            for (name, _) in &frame.arrays {
2737                let s = format!("@{}", name);
2738                if seen.insert(s.clone()) {
2739                    out.push(s);
2740                }
2741            }
2742            for (name, _) in &frame.hashes {
2743                let s = format!("%{}", name);
2744                if seen.insert(s.clone()) {
2745                    out.push(s);
2746                }
2747            }
2748            for (name, _) in &frame.atomic_arrays {
2749                let s = format!("@{}", name);
2750                if seen.insert(s.clone()) {
2751                    out.push(s);
2752                }
2753            }
2754            for (name, _) in &frame.atomic_hashes {
2755                let s = format!("%{}", name);
2756                if seen.insert(s.clone()) {
2757                    out.push(s);
2758                }
2759            }
2760        }
2761        out.sort();
2762        out
2763    }
2764    /// `capture` — see implementation.
2765    pub fn capture(&mut self) -> Vec<(String, StrykeValue)> {
2766        // Capture wraps simple scalars in CaptureCell so repeat calls of the
2767        // SAME closure share state internally (factory pattern: `sub { ++$n }`
2768        // counts up). Whether the OUTER scope's storage is updated to share
2769        // the same cell — i.e. whether outer mutations are observable to the
2770        // closure (and vice versa) — depends on the mode:
2771        //
2772        //   - default stryke: cell is closure-local. Outer scope keeps its
2773        //     own storage. Outer mutations are NOT observable (DESIGN-001;
2774        //     race-free dispatch into pmap/pfor/async/spawn). Use `mysync`
2775        //     for explicitly-shared variables.
2776        //   - --compat: cell is shared by mutating outer storage to point at
2777        //     the same Arc. Perl 5 shared-storage closure semantics.
2778        let by_ref = crate::compat_mode();
2779        let mut captured = Vec::new();
2780        // Hash-stored scalar dedup: each name in `frame.scalars` has at most ONE binding
2781        // visible to the closure (innermost shadows outer). Without dedup, a name that
2782        // exists in multiple frames — e.g. `$_` restored into a callee frame by an earlier
2783        // `restore_capture`, while the top-level frame still holds the original — would be
2784        // pushed twice. `restore_capture` then declares them sequentially, and the second
2785        // `declare_scalar` write-throughs the first's CaptureCell with another CaptureCell,
2786        // nesting them. One `arc.read()` unwrap then surfaces the inner cell and renders
2787        // as `SCALAR(0x…)`. We walk hash-stored scalars innermost-first and skip names
2788        // already seen — only the innermost binding is captured.
2789        //
2790        // Slot-stored scalars, arrays, and hashes don't need dedup: they iterate
2791        // outer-first so that during `restore_capture` the innermost frame's value is
2792        // declared LAST, winning slot-index / hash-key collisions (factory-closure pattern
2793        // depends on this last-write-wins behavior).
2794        let mut seen_hash_scalars: HashSet<String> = HashSet::new();
2795        for frame in self.frames.iter_mut().rev() {
2796            for (k, v) in &mut frame.scalars {
2797                if !seen_hash_scalars.insert(k.clone()) {
2798                    continue;
2799                }
2800                if v.as_capture_cell().is_some() || v.as_scalar_ref().is_some() {
2801                    captured.push((format!("${}", k), v.clone()));
2802                } else if v.is_simple_scalar() {
2803                    let wrapped = StrykeValue::capture_cell(Arc::new(RwLock::new(v.clone())));
2804                    *v = wrapped.clone();
2805                    captured.push((format!("${}", k), wrapped));
2806                } else {
2807                    captured.push((format!("${}", k), v.clone()));
2808                }
2809            }
2810        }
2811        for frame in &mut self.frames {
2812            // Slot-stored scalars are lexical `my` declarations. Closure
2813            // capture rule (DESIGN-001):
2814            //   - default stryke: cell is closure-local. Repeat calls of the
2815            //     same closure share state (factory pattern), but outer
2816            //     mutations are NOT observable. Use `mysync` for shared
2817            //     state.
2818            //   - --compat: cell is shared with outer scope (Perl 5).
2819            for (i, v) in frame.scalar_slots.iter_mut().enumerate() {
2820                if let Some(Some(name)) = frame.scalar_slot_names.get(i) {
2821                    // Cross-storage shadow check: a hash-stored scalar with this
2822                    // name was already captured from an inner frame (e.g. a
2823                    // sub-parameter declared via `apply_sub_signature` in the
2824                    // callee's frame). Capturing the outer slot-stored entry too
2825                    // would put BOTH into the closure's call frame on restore,
2826                    // and `Frame::get_scalar` checks slots before scalars — so
2827                    // the slot-stored OUTER value would shadow the parameter on
2828                    // every closure body lookup. Skip the slot-stored entry to
2829                    // let the hash-stored param win at runtime.
2830                    if !name.is_empty() && seen_hash_scalars.contains(name) {
2831                        continue;
2832                    }
2833                    let cap_val = if v.as_capture_cell().is_some() || v.as_scalar_ref().is_some() {
2834                        v.clone()
2835                    } else {
2836                        let wrapped = StrykeValue::capture_cell(Arc::new(RwLock::new(v.clone())));
2837                        if by_ref {
2838                            *v = wrapped.clone();
2839                        }
2840                        wrapped
2841                    };
2842                    captured.push((format!("$slot:{}:{}", i, name), cap_val));
2843                }
2844            }
2845            for (k, v) in &frame.arrays {
2846                if capture_skip_bootstrap_array(k) {
2847                    continue;
2848                }
2849                if frame.frozen_arrays.contains(k) {
2850                    captured.push((format!("@frozen:{}", k), StrykeValue::array(v.clone())));
2851                } else {
2852                    captured.push((format!("@{}", k), StrykeValue::array(v.clone())));
2853                }
2854            }
2855            for (k, v) in &frame.hashes {
2856                if capture_skip_bootstrap_hash(k) {
2857                    continue;
2858                }
2859                if frame.frozen_hashes.contains(k) {
2860                    captured.push((format!("%frozen:{}", k), StrykeValue::hash(v.clone())));
2861                } else {
2862                    captured.push((format!("%{}", k), StrykeValue::hash(v.clone())));
2863                }
2864            }
2865            for (k, _aa) in &frame.atomic_arrays {
2866                captured.push((
2867                    format!("@sync_{}", k),
2868                    StrykeValue::atomic(Arc::new(Mutex::new(StrykeValue::string(String::new())))),
2869                ));
2870            }
2871            for (k, _ah) in &frame.atomic_hashes {
2872                captured.push((
2873                    format!("%sync_{}", k),
2874                    StrykeValue::atomic(Arc::new(Mutex::new(StrykeValue::string(String::new())))),
2875                ));
2876            }
2877        }
2878        captured
2879    }
2880
2881    /// Extended capture that returns atomic arrays/hashes separately.
2882    pub fn capture_with_atomics(&self) -> ScopeCaptureWithAtomics {
2883        let mut scalars = Vec::new();
2884        let mut arrays = Vec::new();
2885        let mut hashes = Vec::new();
2886        for frame in &self.frames {
2887            for (k, v) in &frame.scalars {
2888                scalars.push((format!("${}", k), v.clone()));
2889            }
2890            for (i, v) in frame.scalar_slots.iter().enumerate() {
2891                if let Some(Some(name)) = frame.scalar_slot_names.get(i) {
2892                    scalars.push((format!("$slot:{}:{}", i, name), v.clone()));
2893                }
2894            }
2895            for (k, v) in &frame.arrays {
2896                if capture_skip_bootstrap_array(k) {
2897                    continue;
2898                }
2899                if frame.frozen_arrays.contains(k) {
2900                    scalars.push((format!("@frozen:{}", k), StrykeValue::array(v.clone())));
2901                } else {
2902                    scalars.push((format!("@{}", k), StrykeValue::array(v.clone())));
2903                }
2904            }
2905            for (k, v) in &frame.hashes {
2906                if capture_skip_bootstrap_hash(k) {
2907                    continue;
2908                }
2909                if frame.frozen_hashes.contains(k) {
2910                    scalars.push((format!("%frozen:{}", k), StrykeValue::hash(v.clone())));
2911                } else {
2912                    scalars.push((format!("%{}", k), StrykeValue::hash(v.clone())));
2913                }
2914            }
2915            for (k, aa) in &frame.atomic_arrays {
2916                arrays.push((k.clone(), aa.clone()));
2917            }
2918            for (k, ah) in &frame.atomic_hashes {
2919                hashes.push((k.clone(), ah.clone()));
2920            }
2921        }
2922        (scalars, arrays, hashes)
2923    }
2924    /// `restore_capture` — see implementation.
2925    pub fn restore_capture(&mut self, captured: &[(String, StrykeValue)]) {
2926        for (name, val) in captured {
2927            if let Some(rest) = name.strip_prefix("$slot:") {
2928                // "$slot:INDEX:NAME" — restore into scalar_slots only.
2929                // `get_scalar` finds slots via `get_scalar_from_slot`, so a separate
2930                // `declare_scalar` is unnecessary and would double-wrap: `set_scalar`
2931                // sees the slot's ScalarRef and writes *through* it, nesting
2932                // `ScalarRef(ScalarRef(inner))`.
2933                if let Some(colon) = rest.find(':') {
2934                    let idx: usize = rest[..colon].parse().unwrap_or(0);
2935                    let sname = &rest[colon + 1..];
2936                    self.declare_scalar_slot(idx as u8, val.clone(), Some(sname));
2937                }
2938            } else if let Some(stripped) = name.strip_prefix('$') {
2939                self.declare_scalar(stripped, val.clone());
2940                // Topic positional slot like `_1`, `_2<`, `_12<<<<` — bump
2941                // `max_active_slot` so the next `set_topic` shifts that slot's
2942                // outer-topic chain. Without this, lazy iterators built from a
2943                // fresh `Interpreter` (FilterStreamIterator etc.) lose `_1<`
2944                // because `set_topic`'s shift loop runs `1..=max_active_slot`
2945                // and that high-water mark resets to 0 in a fresh scope.
2946                if let Some(slot) = parse_positional_topic_slot(stripped) {
2947                    if slot > self.max_active_slot {
2948                        self.max_active_slot = slot;
2949                    }
2950                }
2951            } else if let Some(rest) = name.strip_prefix("@frozen:") {
2952                let arr = val.as_array_vec().unwrap_or_else(|| val.to_list());
2953                self.declare_array_frozen(rest, arr, true);
2954            } else if let Some(rest) = name.strip_prefix("%frozen:") {
2955                if let Some(h) = val.as_hash_map() {
2956                    self.declare_hash_frozen(rest, h.clone(), true);
2957                }
2958            } else if let Some(rest) = name.strip_prefix('@') {
2959                if rest.starts_with("sync_") {
2960                    continue;
2961                }
2962                let arr = val.as_array_vec().unwrap_or_else(|| val.to_list());
2963                self.declare_array(rest, arr);
2964            } else if let Some(rest) = name.strip_prefix('%') {
2965                if rest.starts_with("sync_") {
2966                    continue;
2967                }
2968                if let Some(h) = val.as_hash_map() {
2969                    self.declare_hash(rest, h.clone());
2970                }
2971            }
2972        }
2973    }
2974
2975    /// Restore atomic arrays/hashes from capture_with_atomics.
2976    pub fn restore_atomics(
2977        &mut self,
2978        arrays: &[(String, AtomicArray)],
2979        hashes: &[(String, AtomicHash)],
2980    ) {
2981        if let Some(frame) = self.frames.last_mut() {
2982            for (name, aa) in arrays {
2983                frame.atomic_arrays.push((name.clone(), aa.clone()));
2984            }
2985            for (name, ah) in hashes {
2986                frame.atomic_hashes.push((name.clone(), ah.clone()));
2987            }
2988        }
2989    }
2990}
2991
2992#[cfg(test)]
2993mod tests {
2994    use super::*;
2995    use crate::value::StrykeValue;
2996
2997    #[test]
2998    fn missing_scalar_is_undef() {
2999        let s = Scope::new();
3000        assert!(s.get_scalar("not_declared").is_undef());
3001    }
3002
3003    #[test]
3004    fn inner_frame_shadows_outer_scalar() {
3005        let mut s = Scope::new();
3006        s.declare_scalar("a", StrykeValue::integer(1));
3007        s.push_frame();
3008        s.declare_scalar("a", StrykeValue::integer(2));
3009        assert_eq!(s.get_scalar("a").to_int(), 2);
3010        s.pop_frame();
3011        assert_eq!(s.get_scalar("a").to_int(), 1);
3012    }
3013
3014    #[test]
3015    fn set_scalar_updates_innermost_binding() {
3016        let mut s = Scope::new();
3017        s.declare_scalar("a", StrykeValue::integer(1));
3018        s.push_frame();
3019        s.declare_scalar("a", StrykeValue::integer(2));
3020        let _ = s.set_scalar("a", StrykeValue::integer(99));
3021        assert_eq!(s.get_scalar("a").to_int(), 99);
3022        s.pop_frame();
3023        assert_eq!(s.get_scalar("a").to_int(), 1);
3024    }
3025
3026    #[test]
3027    fn array_negative_index_reads_from_end() {
3028        let mut s = Scope::new();
3029        s.declare_array(
3030            "a",
3031            vec![
3032                StrykeValue::integer(10),
3033                StrykeValue::integer(20),
3034                StrykeValue::integer(30),
3035            ],
3036        );
3037        assert_eq!(s.get_array_element("a", -1).to_int(), 30);
3038    }
3039
3040    #[test]
3041    fn set_array_element_extends_array_with_undef_gaps() {
3042        let mut s = Scope::new();
3043        s.declare_array("a", vec![]);
3044        s.set_array_element("a", 2, StrykeValue::integer(7))
3045            .unwrap();
3046        assert_eq!(s.get_array_element("a", 2).to_int(), 7);
3047        assert!(s.get_array_element("a", 0).is_undef());
3048    }
3049
3050    #[test]
3051    fn capture_restore_roundtrip_scalar() {
3052        let mut s = Scope::new();
3053        s.declare_scalar("n", StrykeValue::integer(42));
3054        let cap = s.capture();
3055        let mut t = Scope::new();
3056        t.restore_capture(&cap);
3057        assert_eq!(t.get_scalar("n").to_int(), 42);
3058    }
3059
3060    #[test]
3061    fn capture_restore_roundtrip_lexical_array_and_hash() {
3062        let mut s = Scope::new();
3063        s.declare_array("a", vec![StrykeValue::integer(1), StrykeValue::integer(2)]);
3064        let mut m = IndexMap::new();
3065        m.insert("k".to_string(), StrykeValue::integer(99));
3066        s.declare_hash("h", m);
3067        let cap = s.capture();
3068        let mut t = Scope::new();
3069        t.restore_capture(&cap);
3070        assert_eq!(t.get_array_element("a", 1).to_int(), 2);
3071        assert_eq!(t.get_hash_element("h", "k").to_int(), 99);
3072    }
3073
3074    #[test]
3075    fn hash_get_set_delete_exists() {
3076        let mut s = Scope::new();
3077        let mut m = IndexMap::new();
3078        m.insert("k".to_string(), StrykeValue::integer(1));
3079        s.declare_hash("h", m);
3080        assert_eq!(s.get_hash_element("h", "k").to_int(), 1);
3081        assert!(s.exists_hash_element("h", "k"));
3082        s.set_hash_element("h", "k", StrykeValue::integer(99))
3083            .unwrap();
3084        assert_eq!(s.get_hash_element("h", "k").to_int(), 99);
3085        let del = s.delete_hash_element("h", "k").unwrap();
3086        assert_eq!(del.to_int(), 99);
3087        assert!(!s.exists_hash_element("h", "k"));
3088    }
3089
3090    #[test]
3091    fn inner_frame_shadows_outer_hash_name() {
3092        let mut s = Scope::new();
3093        let mut outer = IndexMap::new();
3094        outer.insert("k".to_string(), StrykeValue::integer(1));
3095        s.declare_hash("h", outer);
3096        s.push_frame();
3097        let mut inner = IndexMap::new();
3098        inner.insert("k".to_string(), StrykeValue::integer(2));
3099        s.declare_hash("h", inner);
3100        assert_eq!(s.get_hash_element("h", "k").to_int(), 2);
3101        s.pop_frame();
3102        assert_eq!(s.get_hash_element("h", "k").to_int(), 1);
3103    }
3104
3105    #[test]
3106    fn inner_frame_shadows_outer_array_name() {
3107        let mut s = Scope::new();
3108        s.declare_array("a", vec![StrykeValue::integer(1)]);
3109        s.push_frame();
3110        s.declare_array("a", vec![StrykeValue::integer(2), StrykeValue::integer(3)]);
3111        assert_eq!(s.get_array_element("a", 1).to_int(), 3);
3112        s.pop_frame();
3113        assert_eq!(s.get_array_element("a", 0).to_int(), 1);
3114    }
3115
3116    #[test]
3117    fn pop_frame_never_removes_global_frame() {
3118        let mut s = Scope::new();
3119        s.declare_scalar("x", StrykeValue::integer(1));
3120        s.pop_frame();
3121        s.pop_frame();
3122        assert_eq!(s.get_scalar("x").to_int(), 1);
3123    }
3124
3125    #[test]
3126    fn empty_array_declared_has_zero_length() {
3127        let mut s = Scope::new();
3128        s.declare_array("a", vec![]);
3129        assert_eq!(s.get_array("a").len(), 0);
3130    }
3131
3132    #[test]
3133    fn depth_increments_with_push_frame() {
3134        let mut s = Scope::new();
3135        let d0 = s.depth();
3136        s.push_frame();
3137        assert_eq!(s.depth(), d0 + 1);
3138        s.pop_frame();
3139        assert_eq!(s.depth(), d0);
3140    }
3141
3142    #[test]
3143    fn pop_to_depth_unwinds_to_target() {
3144        let mut s = Scope::new();
3145        s.push_frame();
3146        s.push_frame();
3147        let target = s.depth() - 1;
3148        s.pop_to_depth(target);
3149        assert_eq!(s.depth(), target);
3150    }
3151
3152    #[test]
3153    fn array_len_and_push_pop_roundtrip() {
3154        let mut s = Scope::new();
3155        s.declare_array("a", vec![]);
3156        assert_eq!(s.array_len("a"), 0);
3157        s.push_to_array("a", StrykeValue::integer(1)).unwrap();
3158        s.push_to_array("a", StrykeValue::integer(2)).unwrap();
3159        assert_eq!(s.array_len("a"), 2);
3160        assert_eq!(s.pop_from_array("a").unwrap().to_int(), 2);
3161        assert_eq!(s.pop_from_array("a").unwrap().to_int(), 1);
3162        assert!(s.pop_from_array("a").unwrap().is_undef());
3163    }
3164
3165    #[test]
3166    fn shift_from_array_drops_front() {
3167        let mut s = Scope::new();
3168        s.declare_array("a", vec![StrykeValue::integer(1), StrykeValue::integer(2)]);
3169        assert_eq!(s.shift_from_array("a").unwrap().to_int(), 1);
3170        assert_eq!(s.array_len("a"), 1);
3171    }
3172
3173    #[test]
3174    fn atomic_mutate_increments_wrapped_scalar() {
3175        use parking_lot::Mutex;
3176        use std::sync::Arc;
3177        let mut s = Scope::new();
3178        s.declare_scalar(
3179            "n",
3180            StrykeValue::atomic(Arc::new(Mutex::new(StrykeValue::integer(10)))),
3181        );
3182        let v = s
3183            .atomic_mutate("n", |old| StrykeValue::integer(old.to_int() + 5))
3184            .expect("atomic_mutate on atomic-backed scalar must not fail");
3185        assert_eq!(v.to_int(), 15);
3186        assert_eq!(s.get_scalar("n").to_int(), 15);
3187    }
3188
3189    #[test]
3190    fn atomic_mutate_post_returns_old_value() {
3191        use parking_lot::Mutex;
3192        use std::sync::Arc;
3193        let mut s = Scope::new();
3194        s.declare_scalar(
3195            "n",
3196            StrykeValue::atomic(Arc::new(Mutex::new(StrykeValue::integer(7)))),
3197        );
3198        let old = s
3199            .atomic_mutate_post("n", |v| StrykeValue::integer(v.to_int() + 1))
3200            .expect("atomic_mutate_post on atomic-backed scalar must not fail");
3201        assert_eq!(old.to_int(), 7);
3202        assert_eq!(s.get_scalar("n").to_int(), 8);
3203    }
3204
3205    #[test]
3206    fn get_scalar_raw_keeps_atomic_wrapper() {
3207        use parking_lot::Mutex;
3208        use std::sync::Arc;
3209        let mut s = Scope::new();
3210        s.declare_scalar(
3211            "n",
3212            StrykeValue::atomic(Arc::new(Mutex::new(StrykeValue::integer(3)))),
3213        );
3214        assert!(s.get_scalar_raw("n").is_atomic());
3215        assert!(!s.get_scalar("n").is_atomic());
3216    }
3217
3218    #[test]
3219    fn missing_array_element_is_undef() {
3220        let mut s = Scope::new();
3221        s.declare_array("a", vec![StrykeValue::integer(1)]);
3222        assert!(s.get_array_element("a", 99).is_undef());
3223    }
3224
3225    #[test]
3226    fn restore_atomics_puts_atomic_containers_in_frame() {
3227        use indexmap::IndexMap;
3228        use parking_lot::Mutex;
3229        use std::sync::Arc;
3230        let mut s = Scope::new();
3231        let aa = AtomicArray(Arc::new(Mutex::new(vec![StrykeValue::integer(1)])));
3232        let ah = AtomicHash(Arc::new(Mutex::new(IndexMap::new())));
3233        s.restore_atomics(&[("ax".into(), aa.clone())], &[("hx".into(), ah.clone())]);
3234        assert_eq!(s.get_array_element("ax", 0).to_int(), 1);
3235        assert_eq!(s.array_len("ax"), 1);
3236        s.set_hash_element("hx", "k", StrykeValue::integer(2))
3237            .unwrap();
3238        assert_eq!(s.get_hash_element("hx", "k").to_int(), 2);
3239    }
3240
3241    // ── topic_alias / outer-chain aliasing ──────────────────────────────
3242    //
3243    // The debugger and `for (@arr) { … }` loops depend on `$_` ↔ `$_0`
3244    // (and outer-chain analogues `_<` ↔ `_0<`) reading the same slot.
3245    // If `topic_alias` ever drops a mapping the user sees ghost values
3246    // in the Variables panel where `$_` and `$_0` disagree.
3247
3248    #[test]
3249    fn topic_alias_pairs_underscore_with_zero() {
3250        assert_eq!(topic_alias("_").as_deref(), Some("_0"));
3251        assert_eq!(topic_alias("_0").as_deref(), Some("_"));
3252    }
3253
3254    #[test]
3255    fn topic_alias_pairs_outer_chain_with_zero_form() {
3256        // `_<` ↔ `_0<`, `_<<<` ↔ `_0<<<`, etc.
3257        assert_eq!(topic_alias("_<").as_deref(), Some("_0<"));
3258        assert_eq!(topic_alias("_0<").as_deref(), Some("_<"));
3259        assert_eq!(topic_alias("_<<<").as_deref(), Some("_0<<<"));
3260        assert_eq!(topic_alias("_0<<<").as_deref(), Some("_<<<"));
3261    }
3262
3263    #[test]
3264    fn topic_alias_has_no_pair_for_other_positionals() {
3265        // `_1`, `_2`, etc. are positional-only — no `$_` alias.
3266        assert!(topic_alias("_1").is_none());
3267        assert!(topic_alias("_2").is_none());
3268        assert!(topic_alias("_42").is_none());
3269        // `_<+digits` is mixed (slice index) — not a chevron-only chain.
3270        assert!(topic_alias("_<5").is_none());
3271        // Plain identifiers.
3272        assert!(topic_alias("foo").is_none());
3273        assert!(topic_alias("_foo").is_none());
3274    }
3275
3276    // ── parse_positional_topic_slot ─────────────────────────────────────
3277
3278    #[test]
3279    fn positional_topic_slot_parses_underscore_n() {
3280        // Only N >= 1 — `_0` is the topic alias for `_` (see
3281        // [`topic_alias`]), not a positional slot.
3282        assert_eq!(parse_positional_topic_slot("_1"), Some(1));
3283        assert_eq!(parse_positional_topic_slot("_2"), Some(2));
3284        assert_eq!(parse_positional_topic_slot("_42"), Some(42));
3285    }
3286
3287    #[test]
3288    fn positional_topic_slot_rejects_non_positional_names() {
3289        assert!(
3290            parse_positional_topic_slot("_").is_none(),
3291            "bare _ has no slot"
3292        );
3293        assert!(
3294            parse_positional_topic_slot("_0").is_none(),
3295            "_0 is the topic alias, not positional"
3296        );
3297        assert!(parse_positional_topic_slot("_foo").is_none(), "named");
3298        assert!(parse_positional_topic_slot("foo").is_none());
3299        assert!(parse_positional_topic_slot("").is_none());
3300    }
3301}