Skip to main content

stryke/
scope.rs

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