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