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