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