Skip to main content

stryke/
dap.rs

1//! Debug Adapter Protocol (DAP) server for stryke.
2//!
3//! Started via `st --dap [file.stk]`. Speaks DAP over stdio using the same
4//! `Content-Length`-framed JSON-RPC as LSP. Wraps [`crate::debugger::Debugger`]
5//! so existing breakpoint / step / variable inspection logic is reused unchanged.
6//!
7//! ## Threading model
8//!
9//! * **Main thread** spawns a reader thread, then once the client sends
10//!   `launch`, runs the VM in-place.
11//! * **Reader thread** parses incoming DAP messages from stdin, mutates the
12//!   shared [`DapShared`] state, and signals the VM thread via condvar when
13//!   resuming.
14//! * **VM thread** = main thread after `launch`. On each line stop it locks the
15//!   shared state, captures a snapshot, emits a `stopped` event, and condvar-
16//!   waits for a resume command.
17//!
18//! ## Stdout
19//!
20//! Both threads write to stdout through `DapShared.writer` (a `Mutex<Stdout>`).
21//! All outgoing messages get a monotonic `seq`.
22//!
23//! ## What's supported (v1)
24//!
25//! * `initialize` / `configurationDone` / `disconnect` / `terminate`
26//! * `launch` (with `program` + `noDebug` + `args` + `cwd`)
27//! * `setBreakpoints` (line breakpoints; conditions ignored for now)
28//! * `setFunctionBreakpoints` (sub-name breakpoints)
29//! * `threads` (single thread)
30//! * `stackTrace` / `scopes` / `variables` (locals + globals from snapshot)
31//! * `continue` / `next` / `stepIn` / `stepOut` / `pause`
32//! * `evaluate` (REPL — uses `Debugger::print_variable` for simple vars)
33//!
34//! Not yet: conditional / hit-count breakpoints, exception breakpoints, watch
35//! expressions, sub-line stepping, remote attach, child process tracking.
36
37use serde::{Deserialize, Serialize};
38use serde_json::{json, Value};
39use std::collections::HashMap;
40use std::io::{self, BufRead, BufReader, Read, Write};
41use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
42use std::sync::{Arc, Condvar, Mutex};
43use std::thread;
44
45use crate::debugger::DebugAction;
46
47const MAX_VAR_REPR: usize = 200;
48
49// ─── DAP wire types ────────────────────────────────────────────────────────
50
51#[derive(Debug, Serialize, Deserialize)]
52struct DapRequest {
53    seq: u64,
54    #[serde(rename = "type")]
55    msg_type: String,
56    command: String,
57    #[serde(default)]
58    arguments: Value,
59}
60
61// ─── Shared state ───────────────────────────────────────────────────────────
62
63/// Snapshot of where the VM is paused. Captured by the VM thread *before*
64/// blocking on the condvar so the reader thread can answer `stackTrace` /
65/// `scopes` / `variables` requests without touching the VM.
66#[derive(Default, Clone)]
67pub(crate) struct PauseSnapshot {
68    pub file: String,
69    pub line: usize,
70    pub reason: String, // "breakpoint" | "step" | "pause" | "entry"
71    pub frames: Vec<FrameSnap>,
72    pub locals: Vec<VarSnap>,
73    pub globals: Vec<VarSnap>,
74    /// Container varRef → children. Built by [`capture_locals_with_map`] by walking
75    /// nested arrays / hashes recursively. The DAP `variables` request reads
76    /// from this map for any non-scope varRef.
77    pub var_ref_map: HashMap<u32, Vec<VarChild>>,
78}
79
80#[derive(Clone)]
81pub(crate) struct FrameSnap {
82    pub name: String,
83    pub file: String,
84    pub line: usize,
85}
86
87#[derive(Clone)]
88pub(crate) struct VarSnap {
89    pub name: String, // includes sigil: "$x", "@arr", "%h"
90    pub repr: String,
91    pub kind: String, // "scalar" | "array" | "hash"
92    /// 0 = leaf. Non-zero = variablesReference the client should use to
93    /// expand this entry into its key/value children. Resolved through
94    /// [`PauseSnapshot::var_ref_map`].
95    pub var_ref: u32,
96}
97
98/// One child row inside an expanded container, used recursively.
99#[derive(Clone)]
100pub(crate) struct VarChild {
101    pub name: String,
102    pub repr: String,
103    /// 0 = leaf scalar/string. Non-zero = container with further children
104    /// (looked up via [`PauseSnapshot::var_ref_map`]).
105    pub var_ref: u32,
106}
107
108struct SharedInner {
109    pending_action: Option<DebugAction>,
110    is_paused: bool,
111    snapshot: PauseSnapshot,
112    pause_request: bool, // client asked us to pause asap
113}
114
115pub struct DapShared {
116    inner: Mutex<SharedInner>,
117    cv: Condvar,
118    seq: AtomicU64,
119    writer: Mutex<Box<dyn Write + Send>>,
120    pub configuration_done: AtomicBool,
121    pub disconnected: AtomicBool,
122}
123
124impl DapShared {
125    fn new(writer: Box<dyn Write + Send>) -> Arc<Self> {
126        Arc::new(Self {
127            inner: Mutex::new(SharedInner {
128                pending_action: None,
129                is_paused: false,
130                snapshot: PauseSnapshot::default(),
131                pause_request: false,
132            }),
133            cv: Condvar::new(),
134            seq: AtomicU64::new(1),
135            writer: Mutex::new(writer),
136            configuration_done: AtomicBool::new(false),
137            disconnected: AtomicBool::new(false),
138        })
139    }
140
141    /// Called by the VM thread when it has detected a stop. Captures the
142    /// snapshot, sends a `stopped` event, then condvar-waits for resume.
143    pub(crate) fn pause(&self, snap: PauseSnapshot) -> DebugAction {
144        // Flush stdout/stderr so any `p`/`print`/`say` output the user
145        // produced since the last pause is visible in the Console BEFORE the
146        // suspend UI shows. Without this, stdout is block-buffered (piped to
147        // OSProcessHandler) and `print "x"` followed by a breakpoint leaves
148        // the Console looking empty until the buffer fills or the program
149        // exits.
150        let _ = std::io::Write::flush(&mut std::io::stdout());
151        let _ = std::io::Write::flush(&mut std::io::stderr());
152        {
153            let mut s = self.inner.lock().expect("dap lock");
154            s.snapshot = snap.clone();
155            s.is_paused = true;
156            s.pending_action = None;
157            s.pause_request = false;
158        }
159        self.emit_event(
160            "stopped",
161            json!({
162                "reason": snap.reason,
163                "threadId": 1,
164                "allThreadsStopped": true,
165                "preserveFocusHint": false,
166                "description": snap.reason,
167                "text": format!("{}:{}", snap.file, snap.line),
168            }),
169        );
170        let mut guard = self.inner.lock().expect("dap lock");
171        while guard.pending_action.is_none() && !self.disconnected.load(Ordering::SeqCst) {
172            guard = self.cv.wait(guard).expect("dap cv");
173        }
174        let action = guard.pending_action.take().unwrap_or(DebugAction::Continue);
175        guard.is_paused = false;
176        action
177    }
178
179    pub fn was_disconnected(&self) -> bool {
180        self.disconnected.load(Ordering::SeqCst)
181    }
182
183    pub fn want_pause(&self) -> bool {
184        self.inner.lock().map(|g| g.pause_request).unwrap_or(false)
185    }
186
187    fn resume_with(&self, action: DebugAction) {
188        let mut g = self.inner.lock().expect("dap lock");
189        g.pending_action = Some(action);
190        self.cv.notify_all();
191    }
192
193    fn next_seq(&self) -> u64 {
194        self.seq.fetch_add(1, Ordering::SeqCst)
195    }
196
197    fn write_message(&self, body: Value) {
198        let s = serde_json::to_string(&body).unwrap_or_else(|_| "{}".to_string());
199        let mut w = self.writer.lock().expect("dap writer");
200        let _ = write!(w, "Content-Length: {}\r\n\r\n{}", s.len(), s);
201        let _ = w.flush();
202    }
203
204    fn emit_response(&self, req: &DapRequest, success: bool, body: Value) {
205        let seq = self.next_seq();
206        let msg = json!({
207            "seq": seq,
208            "type": "response",
209            "request_seq": req.seq,
210            "success": success,
211            "command": req.command,
212            "body": body,
213        });
214        self.write_message(msg);
215    }
216
217    pub fn emit_event(&self, event: &str, body: Value) {
218        let seq = self.next_seq();
219        // INFO for milestones the user cares about (stopped/terminated/exited);
220        // TRACE for chatty ones (`output`, `thread`, `module`, etc.) so the
221        // default INFO log stays readable while DEBUG/TRACE unlock the firehose.
222        let milestone = matches!(
223            event,
224            "stopped" | "terminated" | "exited" | "initialized" | "process" | "breakpoint"
225        );
226        if milestone {
227            crate::slog_info!("dap.evt", "→ {} seq={}", event, seq);
228        } else {
229            crate::slog_trace!("dap.evt", "→ {} seq={}", event, seq);
230        }
231        let msg = json!({
232            "seq": seq,
233            "type": "event",
234            "event": event,
235            "body": body,
236        });
237        self.write_message(msg);
238    }
239}
240
241// ─── Reader / dispatch ──────────────────────────────────────────────────────
242
243/// Spawn the DAP reader thread reading from an arbitrary `Read` source
244/// (stdin for stdio mode; a TCP socket for socket mode). Returns a join
245/// handle and a "launch parameters" channel that the main thread blocks on
246/// before starting the VM.
247pub fn spawn_reader_with_input(
248    shared: Arc<DapShared>,
249    bp_state: Arc<Mutex<BreakpointState>>,
250    input: Box<dyn Read + Send>,
251) -> (
252    thread::JoinHandle<()>,
253    std::sync::mpsc::Receiver<LaunchParams>,
254) {
255    let (tx, rx) = std::sync::mpsc::channel::<LaunchParams>();
256    let h = thread::spawn(move || {
257        let mut reader = BufReader::new(input);
258        loop {
259            let body = match read_message(&mut reader) {
260                Ok(Some(b)) => b,
261                Ok(None) => break,
262                Err(_) => break,
263            };
264            let req: DapRequest = match serde_json::from_slice(&body) {
265                Ok(r) => r,
266                Err(_) => continue,
267            };
268            handle_request(&shared, &bp_state, &tx, &req);
269            if shared.was_disconnected() {
270                break;
271            }
272        }
273        // Stream closed → release any waiting VM thread
274        shared.resume_with(DebugAction::Quit);
275        shared.disconnected.store(true, Ordering::SeqCst);
276    });
277    (h, rx)
278}
279
280fn read_message<R: Read>(reader: &mut BufReader<R>) -> io::Result<Option<Vec<u8>>> {
281    let mut content_length: Option<usize> = None;
282    loop {
283        let mut line = String::new();
284        let n = reader.read_line(&mut line)?;
285        if n == 0 {
286            return Ok(None);
287        }
288        let line = line.trim_end_matches(['\r', '\n']);
289        if line.is_empty() {
290            break;
291        }
292        if let Some(rest) = line.strip_prefix("Content-Length:") {
293            content_length = rest.trim().parse().ok();
294        }
295    }
296    let Some(len) = content_length else {
297        return Ok(Some(Vec::new()));
298    };
299    let mut body = vec![0u8; len];
300    reader.read_exact(&mut body)?;
301    Ok(Some(body))
302}
303
304// ─── Launch + breakpoint state ──────────────────────────────────────────────
305
306#[derive(Debug, Clone, Default)]
307pub struct LaunchParams {
308    pub program: String,
309    pub args: Vec<String>,
310    pub cwd: Option<String>,
311    pub no_debug: bool,
312    pub stop_on_entry: bool,
313    pub interpreter_args: Vec<String>,
314    pub no_interop: bool,
315}
316
317#[derive(Debug, Default)]
318pub struct BreakpointState {
319    /// Line breakpoints keyed by absolute file path → set of lines.
320    pub line_breakpoints: HashMap<String, Vec<usize>>,
321    pub function_breakpoints: Vec<String>,
322    /// Step mode set by the reader thread; consumed by `Debugger::prompt`
323    /// after it wakes from condvar.
324    pub pending_step: Option<StepKind>,
325}
326
327// ─── Request handlers ───────────────────────────────────────────────────────
328
329fn handle_request(
330    shared: &Arc<DapShared>,
331    bp_state: &Arc<Mutex<BreakpointState>>,
332    launch_tx: &std::sync::mpsc::Sender<LaunchParams>,
333    req: &DapRequest,
334) {
335    crate::slog_trace!("dap.req", "seq={} command={}", req.seq, req.command);
336    match req.command.as_str() {
337        "initialize" => {
338            shared.emit_response(
339                req,
340                true,
341                json!({
342                    "supportsConfigurationDoneRequest": true,
343                    "supportsFunctionBreakpoints": true,
344                    "supportsConditionalBreakpoints": false,
345                    "supportsHitConditionalBreakpoints": false,
346                    "supportsEvaluateForHovers": true,
347                    "supportsTerminateRequest": true,
348                    "supportsRestartRequest": false,
349                    "supportsStepInTargetsRequest": false,
350                    "supportsSetVariable": false,
351                    "supportsCompletionsRequest": false,
352                    "supportsLoadedSourcesRequest": false,
353                    "supportsExceptionInfoRequest": false,
354                    "supportsExceptionOptions": false,
355                    "supportsValueFormattingOptions": false,
356                    "supportsLogPoints": false,
357                    "supportsModulesRequest": false,
358                    "supportsRestartFrame": false,
359                    "supportsGotoTargetsRequest": false,
360                    "supportsStepBack": false,
361                }),
362            );
363            shared.emit_event("initialized", json!({}));
364        }
365        "setBreakpoints" => {
366            let path = req
367                .arguments
368                .get("source")
369                .and_then(|s| s.get("path"))
370                .and_then(|p| p.as_str())
371                .unwrap_or("")
372                .to_string();
373            let bps = req
374                .arguments
375                .get("breakpoints")
376                .and_then(|b| b.as_array())
377                .map(|arr| {
378                    arr.iter()
379                        .filter_map(|b| b.get("line").and_then(|l| l.as_u64()))
380                        .map(|l| l as usize)
381                        .collect::<Vec<_>>()
382                })
383                .unwrap_or_default();
384            crate::slog_info!("dap.bp", "setBreakpoints path={} lines={:?}", path, bps);
385            {
386                let mut bp = bp_state.lock().expect("bp lock");
387                bp.line_breakpoints.insert(path.clone(), bps.clone());
388            }
389            let verified: Vec<Value> = bps
390                .iter()
391                .map(|l| {
392                    json!({
393                        "verified": true,
394                        "line": *l,
395                        "source": { "path": path }
396                    })
397                })
398                .collect();
399            shared.emit_response(req, true, json!({ "breakpoints": verified }));
400        }
401        "setFunctionBreakpoints" => {
402            let fbps: Vec<String> = req
403                .arguments
404                .get("breakpoints")
405                .and_then(|b| b.as_array())
406                .map(|arr| {
407                    arr.iter()
408                        .filter_map(|b| b.get("name").and_then(|n| n.as_str()).map(String::from))
409                        .collect()
410                })
411                .unwrap_or_default();
412            {
413                let mut bp = bp_state.lock().expect("bp lock");
414                bp.function_breakpoints = fbps.clone();
415            }
416            let body: Vec<Value> = fbps.iter().map(|_| json!({ "verified": true })).collect();
417            shared.emit_response(req, true, json!({ "breakpoints": body }));
418        }
419        "setExceptionBreakpoints" => {
420            shared.emit_response(req, true, json!({ "breakpoints": [] }));
421        }
422        "configurationDone" => {
423            shared.configuration_done.store(true, Ordering::SeqCst);
424            shared.emit_response(req, true, json!({}));
425        }
426        "launch" => {
427            let lp = LaunchParams {
428                program: req
429                    .arguments
430                    .get("program")
431                    .and_then(|v| v.as_str())
432                    .unwrap_or("")
433                    .to_string(),
434                args: req
435                    .arguments
436                    .get("args")
437                    .and_then(|v| v.as_array())
438                    .map(|a| {
439                        a.iter()
440                            .filter_map(|s| s.as_str().map(String::from))
441                            .collect()
442                    })
443                    .unwrap_or_default(),
444                cwd: req
445                    .arguments
446                    .get("cwd")
447                    .and_then(|v| v.as_str())
448                    .map(String::from),
449                no_debug: req
450                    .arguments
451                    .get("noDebug")
452                    .and_then(|v| v.as_bool())
453                    .unwrap_or(false),
454                stop_on_entry: req
455                    .arguments
456                    .get("stopOnEntry")
457                    .and_then(|v| v.as_bool())
458                    .unwrap_or(false),
459                interpreter_args: req
460                    .arguments
461                    .get("interpreterArgs")
462                    .and_then(|v| v.as_array())
463                    .map(|a| {
464                        a.iter()
465                            .filter_map(|s| s.as_str().map(String::from))
466                            .collect()
467                    })
468                    .unwrap_or_default(),
469                no_interop: req
470                    .arguments
471                    .get("noInterop")
472                    .and_then(|v| v.as_bool())
473                    .unwrap_or(false),
474            };
475            crate::slog_info!(
476                "dap.launch",
477                "program={} stopOnEntry={} noDebug={} cwd={:?}",
478                lp.program,
479                lp.stop_on_entry,
480                lp.no_debug,
481                lp.cwd
482            );
483            let _ = launch_tx.send(lp);
484            shared.emit_response(req, true, json!({}));
485        }
486        "threads" => {
487            shared.emit_response(
488                req,
489                true,
490                json!({
491                    "threads": [
492                        { "id": 1, "name": "main" }
493                    ]
494                }),
495            );
496        }
497        "stackTrace" => {
498            let snap = shared.inner.lock().expect("dap lock").snapshot.clone();
499            let frames: Vec<Value> = snap
500                .frames
501                .iter()
502                .enumerate()
503                .map(|(i, f)| {
504                    json!({
505                        "id": i + 1,
506                        "name": f.name,
507                        "line": f.line,
508                        "column": 1,
509                        "source": { "name": leaf(&f.file), "path": f.file }
510                    })
511                })
512                .collect();
513            shared.emit_response(
514                req,
515                true,
516                json!({
517                    "stackFrames": frames,
518                    "totalFrames": frames.len(),
519                }),
520            );
521        }
522        "scopes" => {
523            shared.emit_response(
524                req,
525                true,
526                json!({
527                    "scopes": [
528                        { "name": "Locals",  "variablesReference": 1000, "expensive": false },
529                        { "name": "Globals", "variablesReference": 2000, "expensive": false }
530                    ]
531                }),
532            );
533        }
534        "variables" => {
535            let var_ref = req
536                .arguments
537                .get("variablesReference")
538                .and_then(|v| v.as_u64())
539                .unwrap_or(0) as u32;
540            let snap = shared.inner.lock().expect("dap lock").snapshot.clone();
541            let vars: Vec<Value> = match var_ref {
542                1000 => snap
543                    .locals
544                    .iter()
545                    .map(|v| {
546                        json!({
547                            "name": v.name,
548                            "value": v.repr,
549                            "type": v.kind,
550                            "variablesReference": v.var_ref,
551                        })
552                    })
553                    .collect(),
554                2000 => snap
555                    .globals
556                    .iter()
557                    .map(|v| {
558                        json!({
559                            "name": v.name,
560                            "value": v.repr,
561                            "type": v.kind,
562                            "variablesReference": v.var_ref,
563                        })
564                    })
565                    .collect(),
566                _ => snap
567                    .var_ref_map
568                    .get(&var_ref)
569                    .map(|children| {
570                        children
571                            .iter()
572                            .map(|c| {
573                                json!({
574                                    "name": c.name,
575                                    "value": c.repr,
576                                    "type": "",
577                                    "variablesReference": c.var_ref,
578                                })
579                            })
580                            .collect::<Vec<Value>>()
581                    })
582                    .unwrap_or_default(),
583            };
584            shared.emit_response(req, true, json!({ "variables": vars }));
585        }
586        "continue" => {
587            crate::slog_debug!("dap.flow", "continue");
588            shared.resume_with(DebugAction::Continue);
589            shared.emit_response(req, true, json!({ "allThreadsContinued": true }));
590        }
591        "next" => {
592            // CRITICAL ORDER: set pending_step BEFORE resume_with. The VM
593            // thread reads `pending_step` immediately after cv.wait returns,
594            // so the step kind must be in place before we notify the cv.
595            crate::slog_debug!("dap.flow", "next (step over)");
596            request_step(bp_state, StepKind::Over);
597            shared.resume_with(DebugAction::Continue);
598            shared.emit_response(req, true, json!({}));
599        }
600        "stepIn" => {
601            crate::slog_debug!("dap.flow", "stepIn");
602            request_step(bp_state, StepKind::Into);
603            shared.resume_with(DebugAction::Continue);
604            shared.emit_response(req, true, json!({}));
605        }
606        "stepOut" => {
607            crate::slog_debug!("dap.flow", "stepOut");
608            request_step(bp_state, StepKind::Out);
609            shared.resume_with(DebugAction::Continue);
610            shared.emit_response(req, true, json!({}));
611        }
612        "pause" => {
613            crate::slog_debug!("dap.flow", "pause requested");
614            let mut g = shared.inner.lock().expect("dap lock");
615            g.pause_request = true;
616            shared.emit_response(req, true, json!({}));
617        }
618        "evaluate" => {
619            let expr = req
620                .arguments
621                .get("expression")
622                .and_then(|v| v.as_str())
623                .unwrap_or("")
624                .to_string();
625            let snap = shared.inner.lock().expect("dap lock").snapshot.clone();
626            let result = evaluate_expression(&expr, &snap);
627            shared.emit_response(
628                req,
629                true,
630                json!({
631                    "result": result,
632                    "variablesReference": 0,
633                }),
634            );
635        }
636        "terminate" | "disconnect" => {
637            crate::slog_info!("dap", "{} received, tearing down", req.command);
638            shared.disconnected.store(true, Ordering::SeqCst);
639            shared.resume_with(DebugAction::Quit);
640            shared.emit_response(req, true, json!({}));
641            shared.emit_event("terminated", json!({}));
642        }
643        other => {
644            crate::slog_trace!("dap", "unknown command={}", other);
645            shared.emit_response(req, true, json!({}));
646        }
647    }
648}
649
650#[derive(Debug, Clone, Copy)]
651pub enum StepKind {
652    Over,
653    Into,
654    Out,
655}
656
657/// Step requests are routed to the VM via the shared `BreakpointState`. The
658/// debugger picks them up the next time it wakes from condvar.
659fn request_step(bp_state: &Arc<Mutex<BreakpointState>>, kind: StepKind) {
660    if let Ok(mut g) = bp_state.lock() {
661        g.pending_step = Some(kind);
662    }
663}
664
665fn leaf(path: &str) -> String {
666    path.rsplit_once('/')
667        .map(|(_, t)| t.to_string())
668        .unwrap_or_else(|| path.to_string())
669}
670
671fn truncate(s: &str, n: usize) -> String {
672    if s.chars().count() <= n {
673        s.to_string()
674    } else {
675        let mut out: String = s.chars().take(n).collect();
676        out.push('…');
677        out
678    }
679}
680
681/// Evaluate a debugger expression.
682///
683/// 1. **Name lookup** — if the expression is literally a captured variable
684///    name (`$foo`, `@bar`, `%baz`), return its repr from the snapshot. Fast,
685///    no subprocess.
686/// 2. **Expression evaluation** — for anything else (`55 + 3`, `len(@arr)`,
687///    `sqrt(2)`, etc.), spawn a fresh `st -e 'p (<expr>)'` and return the
688///    captured stdout. Cannot reference the paused frame's local variables
689///    (no scope injection yet), but constant/library expressions work.
690fn evaluate_expression(expr: &str, snap: &PauseSnapshot) -> String {
691    let needle = expr.trim();
692    if needle.is_empty() {
693        return String::new();
694    }
695    // (1) Direct name lookup
696    for src in [&snap.locals, &snap.globals] {
697        for v in src.iter() {
698            if v.name == needle {
699                return v.repr.clone();
700            }
701        }
702    }
703    // (2) Fall back to subprocess evaluation
704    let exe = match std::env::current_exe() {
705        Ok(p) => p,
706        Err(e) => return format!("eval: cannot locate stryke binary: {e}"),
707    };
708    // Build a prelude that re-declares the captured user scalars so expressions
709    // like `$a * $b` see the paused-frame values. Skip stryke built-ins; their
710    // names would shadow real specials and `my $!` etc. don't compile.
711    let mut prelude = String::new();
712    for v in &snap.locals {
713        if v.kind != "scalar" {
714            continue;
715        }
716        if !v.name.starts_with('$') {
717            continue;
718        }
719        let bare = &v.name[1..];
720        if bare.is_empty() || is_builtin_like(bare) {
721            continue;
722        }
723        // The repr is already in stryke-source form (e.g. `42`, `"hello"`, `undef`)
724        // produced by `crate::debugger::format_value`.
725        let repr = if v.repr.is_empty() {
726            "undef"
727        } else {
728            v.repr.as_str()
729        };
730        prelude.push_str(&format!("my ${bare} = {repr};\n"));
731    }
732    // Wrap the expression so its value is printed. `p (EXPR)` prints any
733    // scalar/list/hash representation.
734    let wrapped = format!("{prelude}p ({needle})");
735    let output = std::process::Command::new(&exe)
736        .arg("-e")
737        .arg(&wrapped)
738        .stdin(std::process::Stdio::null())
739        .stdout(std::process::Stdio::piped())
740        .stderr(std::process::Stdio::piped())
741        .output();
742    match output {
743        Ok(out) => {
744            if out.status.success() {
745                let s = String::from_utf8_lossy(&out.stdout)
746                    .trim_end_matches('\n')
747                    .to_string();
748                if s.is_empty() {
749                    "(no output)".to_string()
750                } else {
751                    s
752                }
753            } else {
754                let err = String::from_utf8_lossy(&out.stderr);
755                let msg = err.lines().next().unwrap_or("").trim();
756                format!("error: {msg}")
757            }
758        }
759        Err(e) => format!("eval spawn failed: {e}"),
760    }
761}
762
763// ─── Snapshot capture helpers ───────────────────────────────────────────────
764
765/// Base variablesReference for user-defined and built-in arrays/hashes. The
766/// scopes themselves use 1000 (Locals) and 2000 (Globals), so we start
767/// container refs at 10_000 and increment per container. Refs are stable
768/// within a single pause; reset on each `pause()`.
769const CONTAINER_REF_BASE: u32 = 10_000;
770
771/// Walker state shared across the recursive capture.
772struct CaptureCtx<'a> {
773    next_ref: u32,
774    map: &'a mut HashMap<u32, Vec<VarChild>>,
775}
776
777impl<'a> CaptureCtx<'a> {
778    fn alloc_ref(&mut self) -> u32 {
779        let r = self.next_ref;
780        self.next_ref += 1;
781        r
782    }
783}
784
785/// Maximum recursion depth for nested hash/array drill-down. Cyclic refs
786/// will bottom out; deeper than this is shown as the inline `format_value`
787/// repr without an expand triangle.
788const MAX_VAR_DEPTH: u32 = 12;
789
790/// Build a child row for one value (scalar, array, or hash). For containers
791/// at depth < [`MAX_VAR_DEPTH`] this allocates a varRef and recursively
792/// populates [`CaptureCtx::map`] so the IDE can expand it.
793fn build_child(
794    name: String,
795    value: &crate::value::StrykeValue,
796    depth: u32,
797    ctx: &mut CaptureCtx,
798) -> VarChild {
799    // Opaque sketch objects (TDigest, Bloom, HLL, CMS, TopK, ...) — render a
800    // useful summary instead of the bare type name, and make the row
801    // expandable so the user can drill into the actual stats (count, p50,
802    // p99, fpr, ...). Falls through to the generic scalar/container paths
803    // when the value is not a sketch.
804    if let Some(rich) = try_sketch_child(&name, value, depth, ctx) {
805        return rich;
806    }
807    // Hash or hashref — handle both `HeapObject::Hash` and `HeapObject::HashRef`.
808    let hash_contents: Option<indexmap::IndexMap<String, crate::value::StrykeValue>> = value
809        .as_hash_map()
810        .or_else(|| value.as_hash_ref().map(|arc| arc.read().clone()));
811    if let Some(map) = hash_contents {
812        if depth >= MAX_VAR_DEPTH || map.is_empty() {
813            // At depth limit, use the non-recursive short repr — format_value
814            // would recurse the whole subtree and can stack-overflow on
815            // moderately-nested data ($config with `flags => {...}` etc.).
816            return VarChild {
817                name,
818                repr: truncate(&short_scalar_repr(value), MAX_VAR_REPR),
819                var_ref: 0,
820            };
821        }
822        let preview: Vec<String> = map
823            .iter()
824            .take(4)
825            .map(|(k, v)| format!("{k} => {}", short_scalar_repr(v)))
826            .collect();
827        let repr = format!(
828            "[{}] ({}{})",
829            map.len(),
830            preview.join(", "),
831            if map.len() > 4 {
832                format!(", … {} more", map.len() - 4)
833            } else {
834                String::new()
835            },
836        );
837        let var_ref = ctx.alloc_ref();
838        let children: Vec<VarChild> = map
839            .iter()
840            .take(2000)
841            .map(|(k, v)| build_child(k.clone(), v, depth + 1, ctx))
842            .collect();
843        ctx.map.insert(var_ref, children);
844        return VarChild {
845            name,
846            repr: truncate(&repr, MAX_VAR_REPR),
847            var_ref,
848        };
849    }
850    // Array or arrayref. For `HeapObject::ArrayRef` we MUST read through the
851    // `Arc<RwLock<Vec>>` — `value.to_list()` hits the catch-all `vec![self]`
852    // arm for ArrayRef, returning the same ref wrapped, which produces a
853    // self-referential descent that wastes stack until MAX_VAR_DEPTH and can
854    // overflow on otherwise small data.
855    let array_contents: Option<Vec<crate::value::StrykeValue>> =
856        if let Some(arc) = value.as_array_ref() {
857            Some(arc.read().clone())
858        } else if value.as_array_vec().is_some() {
859            Some(value.to_list())
860        } else {
861            None
862        };
863    if let Some(list) = array_contents {
864        if depth >= MAX_VAR_DEPTH || list.is_empty() {
865            return VarChild {
866                name,
867                repr: truncate(&short_scalar_repr(value), MAX_VAR_REPR),
868                var_ref: 0,
869            };
870        }
871        let preview: Vec<String> = list.iter().take(6).map(short_scalar_repr).collect();
872        let repr = format!(
873            "[{}] ({}{})",
874            list.len(),
875            preview.join(", "),
876            if list.len() > 6 {
877                format!(", … {} more", list.len() - 6)
878            } else {
879                String::new()
880            },
881        );
882        let var_ref = ctx.alloc_ref();
883        let children: Vec<VarChild> = list
884            .iter()
885            .take(2000)
886            .enumerate()
887            .map(|(i, v)| build_child(format!("[{i}]"), v, depth + 1, ctx))
888            .collect();
889        ctx.map.insert(var_ref, children);
890        return VarChild {
891            name,
892            repr: truncate(&repr, MAX_VAR_REPR),
893            var_ref,
894        };
895    }
896    // Plain scalar leaf
897    VarChild {
898        name,
899        repr: truncate(&crate::debugger::format_value(value), MAX_VAR_REPR),
900        var_ref: 0,
901    }
902}
903
904/// Format a `f64` for the Variables panel: drop trailing zeros, prefer
905/// fixed notation for "normal" magnitudes, fall back to scientific for very
906/// big / small numbers. Keeps p99 = 87.3 readable instead of 87.30000000000001.
907fn fmt_f(v: f64) -> String {
908    if !v.is_finite() {
909        return v.to_string();
910    }
911    let av = v.abs();
912    if av != 0.0 && !(1e-3..1e15).contains(&av) {
913        return format!("{:e}", v);
914    }
915    // Trim a {:.6} representation: "12.300000" -> "12.3", "5.000000" -> "5".
916    let s = format!("{:.6}", v);
917    if !s.contains('.') {
918        return s;
919    }
920    let trimmed = s.trim_end_matches('0').trim_end_matches('.');
921    if trimmed.is_empty() || trimmed == "-" {
922        "0".to_string()
923    } else {
924        trimmed.to_string()
925    }
926}
927
928/// Helper: produce a leaf row for a sketch sub-stat (e.g. `count`, `p50`).
929fn sketch_leaf(name: &str, repr: String) -> VarChild {
930    VarChild {
931        name: name.to_string(),
932        repr: truncate(&repr, MAX_VAR_REPR),
933        var_ref: 0,
934    }
935}
936
937/// When `value` is a sketch heap object, return a rich [`VarChild`] with a
938/// one-line summary repr AND an expandable `var_ref` whose children are the
939/// useful stats (count, percentiles, fpr, ...). Returns `None` for any
940/// non-sketch value so the caller falls through to the generic logic.
941fn try_sketch_child(
942    name: &str,
943    value: &crate::value::StrykeValue,
944    depth: u32,
945    ctx: &mut CaptureCtx,
946) -> Option<VarChild> {
947    if depth >= MAX_VAR_DEPTH {
948        return None;
949    }
950
951    if let Some(arc) = value.as_tdigest_sketch() {
952        let mut g = arc.lock();
953        let n = g.count();
954        let (repr, children) = if n == 0 {
955            (
956                "TDigestSketch(empty)".to_string(),
957                vec![
958                    sketch_leaf("count", "0".to_string()),
959                    sketch_leaf("compression", g.compression().to_string()),
960                ],
961            )
962        } else {
963            let (mn, mx) = (g.min(), g.max());
964            let (mean, sum) = (g.mean(), g.sum());
965            let (p50, p90, p95, p99) = (
966                g.quantile(0.50),
967                g.quantile(0.90),
968                g.quantile(0.95),
969                g.quantile(0.99),
970            );
971            let compression = g.compression();
972            let repr = format!(
973                "TDigestSketch(n={}, min={}, max={}, p50={}, p99={})",
974                n,
975                fmt_f(mn),
976                fmt_f(mx),
977                fmt_f(p50),
978                fmt_f(p99)
979            );
980            let kids = vec![
981                sketch_leaf("count", n.to_string()),
982                sketch_leaf("min", fmt_f(mn)),
983                sketch_leaf("max", fmt_f(mx)),
984                sketch_leaf("mean", fmt_f(mean)),
985                sketch_leaf("sum", fmt_f(sum)),
986                sketch_leaf("p50", fmt_f(p50)),
987                sketch_leaf("p90", fmt_f(p90)),
988                sketch_leaf("p95", fmt_f(p95)),
989                sketch_leaf("p99", fmt_f(p99)),
990                sketch_leaf("compression", compression.to_string()),
991            ];
992            (repr, kids)
993        };
994        let var_ref = ctx.alloc_ref();
995        ctx.map.insert(var_ref, children);
996        return Some(VarChild {
997            name: name.to_string(),
998            repr: truncate(&repr, MAX_VAR_REPR),
999            var_ref,
1000        });
1001    }
1002
1003    if let Some(arc) = value.as_bloom_filter() {
1004        let g = arc.lock();
1005        let n = g.inserted();
1006        let bits = g.bit_count();
1007        let k = g.k();
1008        let fpr = g.estimated_fpr();
1009        let repr = format!(
1010            "BloomFilter(n={}, bits={}, k={}, fpr={})",
1011            n,
1012            bits,
1013            k,
1014            fmt_f(fpr)
1015        );
1016        let children = vec![
1017            sketch_leaf("inserted", n.to_string()),
1018            sketch_leaf("bit_count", bits.to_string()),
1019            sketch_leaf("k", k.to_string()),
1020            sketch_leaf("estimated_fpr", fmt_f(fpr)),
1021        ];
1022        let var_ref = ctx.alloc_ref();
1023        ctx.map.insert(var_ref, children);
1024        return Some(VarChild {
1025            name: name.to_string(),
1026            repr: truncate(&repr, MAX_VAR_REPR),
1027            var_ref,
1028        });
1029    }
1030
1031    if let Some(arc) = value.as_hll_sketch() {
1032        let g = arc.lock();
1033        let card = g.count();
1034        let p = g.precision();
1035        let m = g.registers_len();
1036        let repr = format!("HllSketch(cardinality={}, p={}, m={})", fmt_f(card), p, m);
1037        let children = vec![
1038            sketch_leaf("cardinality", fmt_f(card)),
1039            sketch_leaf("precision", p.to_string()),
1040            sketch_leaf("registers", m.to_string()),
1041        ];
1042        let var_ref = ctx.alloc_ref();
1043        ctx.map.insert(var_ref, children);
1044        return Some(VarChild {
1045            name: name.to_string(),
1046            repr: truncate(&repr, MAX_VAR_REPR),
1047            var_ref,
1048        });
1049    }
1050
1051    if let Some(arc) = value.as_cms_sketch() {
1052        let g = arc.lock();
1053        let repr = format!("CmsSketch(width={}, depth={})", g.width(), g.depth());
1054        let children = vec![
1055            sketch_leaf("width", g.width().to_string()),
1056            sketch_leaf("depth", g.depth().to_string()),
1057        ];
1058        let var_ref = ctx.alloc_ref();
1059        ctx.map.insert(var_ref, children);
1060        return Some(VarChild {
1061            name: name.to_string(),
1062            repr: truncate(&repr, MAX_VAR_REPR),
1063            var_ref,
1064        });
1065    }
1066
1067    if let Some(arc) = value.as_topk_sketch() {
1068        let g = arc.lock();
1069        let k = g.k();
1070        let n = g.size();
1071        let heavies = g.heavies(k.min(10));
1072        let preview: Vec<String> = heavies
1073            .iter()
1074            .take(3)
1075            .map(|(key, count, _err)| {
1076                let key_s = String::from_utf8_lossy(key);
1077                format!("({}, {})", key_s, count)
1078            })
1079            .collect();
1080        let repr = format!("TopKSketch(k={}, n={}, top=[{}])", k, n, preview.join(", "));
1081        let mut children = vec![
1082            sketch_leaf("k", k.to_string()),
1083            sketch_leaf("size", n.to_string()),
1084        ];
1085        for (i, (key, count, err)) in heavies.iter().enumerate() {
1086            let key_s = String::from_utf8_lossy(key);
1087            children.push(sketch_leaf(
1088                &format!("top[{}]", i),
1089                format!("({}, count={}, err={})", key_s, count, err),
1090            ));
1091        }
1092        let var_ref = ctx.alloc_ref();
1093        ctx.map.insert(var_ref, children);
1094        return Some(VarChild {
1095            name: name.to_string(),
1096            repr: truncate(&repr, MAX_VAR_REPR),
1097            var_ref,
1098        });
1099    }
1100
1101    // ─── User-defined record/object types ──────────────────────────────────
1102    // Struct: `struct Point { x: Num, y: Num }` → drill into named fields.
1103    if let Some(inst) = value.as_struct_inst() {
1104        let values = inst.values.read().clone();
1105        let preview: Vec<String> = inst
1106            .def
1107            .fields
1108            .iter()
1109            .zip(values.iter())
1110            .take(4)
1111            .map(|(f, v)| format!("{}={}", f.name, short_scalar_repr(v)))
1112            .collect();
1113        let repr = format!(
1114            "{}({}{})",
1115            inst.def.name,
1116            preview.join(", "),
1117            if inst.def.fields.len() > 4 {
1118                format!(", … {} more", inst.def.fields.len() - 4)
1119            } else {
1120                String::new()
1121            },
1122        );
1123        let var_ref = ctx.alloc_ref();
1124        let children: Vec<VarChild> = inst
1125            .def
1126            .fields
1127            .iter()
1128            .zip(values.iter())
1129            .map(|(f, v)| build_child(f.name.clone(), v, depth + 1, ctx))
1130            .collect();
1131        ctx.map.insert(var_ref, children);
1132        return Some(VarChild {
1133            name: name.to_string(),
1134            repr: truncate(&repr, MAX_VAR_REPR),
1135            var_ref,
1136        });
1137    }
1138
1139    // Enum: `enum Maybe { Just(T), Nothing }` → show `Type::Variant(data…)`,
1140    // expand to show variant name + carried data (which may itself be a
1141    // struct/array/scalar that further drills down through `build_child`).
1142    if let Some(inst) = value.as_enum_inst() {
1143        let variant = inst.variant_name();
1144        let data_preview = if inst.data.is_undef() {
1145            String::new()
1146        } else {
1147            format!("({})", short_scalar_repr(&inst.data))
1148        };
1149        let repr = format!("{}::{}{}", inst.def.name, variant, data_preview);
1150        let mut children = vec![
1151            sketch_leaf("__variant", variant.to_string()),
1152            sketch_leaf("__variant_idx", inst.variant_idx.to_string()),
1153        ];
1154        if !inst.data.is_undef() {
1155            children.push(build_child("data".to_string(), &inst.data, depth + 1, ctx));
1156        }
1157        let var_ref = ctx.alloc_ref();
1158        ctx.map.insert(var_ref, children);
1159        return Some(VarChild {
1160            name: name.to_string(),
1161            repr: truncate(&repr, MAX_VAR_REPR),
1162            var_ref,
1163        });
1164    }
1165
1166    // Class instance: `class Point { pub x: Num, pub y: Num }`. Drill into
1167    // named fields; expose the ISA chain (parent classes) and the class
1168    // name as synthetic `__class` / `__isa` rows so the debugger can show
1169    // the dispatch target without forcing the user to evaluate `ref $obj`.
1170    if let Some(inst) = value.as_class_inst() {
1171        let values = inst.values.read().clone();
1172        let preview: Vec<String> = inst
1173            .def
1174            .fields
1175            .iter()
1176            .zip(values.iter())
1177            .take(4)
1178            .map(|(f, v)| format!("{}={}", f.name, short_scalar_repr(v)))
1179            .collect();
1180        let repr = format!(
1181            "{}({}{})",
1182            inst.def.name,
1183            preview.join(", "),
1184            if inst.def.fields.len() > 4 {
1185                format!(", … {} more", inst.def.fields.len() - 4)
1186            } else {
1187                String::new()
1188            },
1189        );
1190        let mut children = vec![sketch_leaf("__class", inst.def.name.clone())];
1191        if !inst.isa_chain.is_empty() {
1192            children.push(sketch_leaf(
1193                "__isa",
1194                format!("[{}]", inst.isa_chain.join(", ")),
1195            ));
1196        }
1197        for (f, v) in inst.def.fields.iter().zip(values.iter()) {
1198            let vis_marker = match f.visibility {
1199                crate::ast::Visibility::Private => "-",
1200                crate::ast::Visibility::Protected => "#",
1201                crate::ast::Visibility::Public => "+",
1202            };
1203            children.push(build_child(
1204                format!("{}{}", vis_marker, f.name),
1205                v,
1206                depth + 1,
1207                ctx,
1208            ));
1209        }
1210        let var_ref = ctx.alloc_ref();
1211        ctx.map.insert(var_ref, children);
1212        return Some(VarChild {
1213            name: name.to_string(),
1214            repr: truncate(&repr, MAX_VAR_REPR),
1215            var_ref,
1216        });
1217    }
1218
1219    // Set: ordered set of distinct elements. `PerlSet = IndexMap<String, StrykeValue>`
1220    // where keys are the element string repr. Drill rows show each element
1221    // value (the IndexMap's value side, which is the original `StrykeValue`).
1222    if let Some(arc) = crate::value::set_payload(value) {
1223        let len = arc.len();
1224        let preview: Vec<String> = arc.values().take(6).map(short_scalar_repr).collect();
1225        let repr = format!(
1226            "Set({}){}",
1227            len,
1228            if arc.is_empty() {
1229                String::new()
1230            } else {
1231                format!(
1232                    " {{{}{}}}",
1233                    preview.join(", "),
1234                    if len > 6 {
1235                        format!(", … {} more", len - 6)
1236                    } else {
1237                        String::new()
1238                    }
1239                )
1240            },
1241        );
1242        let var_ref = if arc.is_empty() { 0 } else { ctx.alloc_ref() };
1243        if var_ref != 0 {
1244            let children: Vec<VarChild> = arc
1245                .values()
1246                .take(2000)
1247                .enumerate()
1248                .map(|(i, v)| build_child(format!("[{}]", i), v, depth + 1, ctx))
1249                .collect();
1250            ctx.map.insert(var_ref, children);
1251        }
1252        return Some(VarChild {
1253            name: name.to_string(),
1254            repr: truncate(&repr, MAX_VAR_REPR),
1255            var_ref,
1256        });
1257    }
1258
1259    None
1260}
1261
1262/// One-line scalar repr used inside preview strings — avoids recursing into
1263/// nested containers (use `HASH(?)`/`ARRAY(?)` placeholders there).
1264fn short_scalar_repr(v: &crate::value::StrykeValue) -> String {
1265    if v.as_hash_ref().is_some() || v.as_hash_map().is_some() {
1266        return "{…}".to_string();
1267    }
1268    if v.as_array_ref().is_some() || v.as_array_vec().is_some() {
1269        return "[…]".to_string();
1270    }
1271    crate::debugger::format_value(v)
1272}
1273
1274pub(crate) fn capture_locals_with_map(
1275    scope: &crate::scope::Scope,
1276    map: &mut HashMap<u32, Vec<VarChild>>,
1277) -> Vec<VarSnap> {
1278    let mut ctx = CaptureCtx {
1279        next_ref: CONTAINER_REF_BASE,
1280        map,
1281    };
1282    let mut user: Vec<VarSnap> = Vec::new();
1283    let mut topic: Vec<VarSnap> = Vec::new();
1284    let mut builtin: Vec<VarSnap> = Vec::new();
1285
1286    for name in scope.all_scalar_names().into_iter().take(256) {
1287        if should_hide(&name) {
1288            continue;
1289        }
1290        let v = scope.get_scalar(&name);
1291        // A scalar might HOLD a hashref/arrayref (refs in stryke).
1292        let child = build_child(format!("${name}"), &v, 0, &mut ctx);
1293        let snap = VarSnap {
1294            name: child.name,
1295            repr: child.repr,
1296            kind: "scalar".into(),
1297            var_ref: child.var_ref,
1298        };
1299        if is_magic_block_param(&name) {
1300            topic.push(snap);
1301        } else if is_builtin_like(&name) {
1302            builtin.push(snap);
1303        } else {
1304            user.push(snap);
1305        }
1306    }
1307    for name in scope.all_array_names().into_iter().take(64) {
1308        if should_hide(&name) {
1309            continue;
1310        }
1311        let arr = scope.get_array(&name);
1312        let preview: Vec<String> = arr.iter().take(8).map(short_scalar_repr).collect();
1313        let repr = format!(
1314            "[{}]{}",
1315            arr.len(),
1316            if arr.is_empty() {
1317                String::new()
1318            } else {
1319                format!(
1320                    " ({}{})",
1321                    preview.join(", "),
1322                    if arr.len() > 8 {
1323                        format!(", … {} more", arr.len() - 8)
1324                    } else {
1325                        String::new()
1326                    }
1327                )
1328            },
1329        );
1330        let var_ref = if arr.is_empty() { 0 } else { ctx.alloc_ref() };
1331        if var_ref != 0 {
1332            let children: Vec<VarChild> = arr
1333                .iter()
1334                .take(2000)
1335                .enumerate()
1336                .map(|(i, v)| build_child(format!("[{i}]"), v, 1, &mut ctx))
1337                .collect();
1338            ctx.map.insert(var_ref, children);
1339        }
1340        let snap = VarSnap {
1341            name: format!("@{name}"),
1342            repr: truncate(&repr, MAX_VAR_REPR),
1343            kind: "array".into(),
1344            var_ref,
1345        };
1346        if is_builtin_like(&name) {
1347            builtin.push(snap);
1348        } else {
1349            user.push(snap);
1350        }
1351    }
1352    for name in scope.all_hash_names().into_iter().take(64) {
1353        if should_hide(&name) {
1354            continue;
1355        }
1356        let h = scope.get_hash(&name);
1357        let preview: Vec<String> = h
1358            .iter()
1359            .take(6)
1360            .map(|(k, v)| format!("{k} => {}", short_scalar_repr(v)))
1361            .collect();
1362        let repr = format!(
1363            "[{}]{}",
1364            h.len(),
1365            if h.is_empty() {
1366                String::new()
1367            } else {
1368                format!(
1369                    " ({}{})",
1370                    preview.join(", "),
1371                    if h.len() > 6 {
1372                        format!(", … {} more", h.len() - 6)
1373                    } else {
1374                        String::new()
1375                    }
1376                )
1377            },
1378        );
1379        let var_ref = if h.is_empty() { 0 } else { ctx.alloc_ref() };
1380        if var_ref != 0 {
1381            let children: Vec<VarChild> = h
1382                .iter()
1383                .take(2000)
1384                .map(|(k, v)| build_child(k.clone(), v, 1, &mut ctx))
1385                .collect();
1386            ctx.map.insert(var_ref, children);
1387        }
1388        let snap = VarSnap {
1389            name: format!("%{name}"),
1390            repr: truncate(&repr, MAX_VAR_REPR),
1391            kind: "hash".into(),
1392            var_ref,
1393        };
1394        if is_builtin_like(&name) {
1395            builtin.push(snap);
1396        } else {
1397            user.push(snap);
1398        }
1399    }
1400
1401    let by_sigil_name = |a: &VarSnap, b: &VarSnap| a.name.cmp(&b.name);
1402    user.sort_by(by_sigil_name);
1403    builtin.sort_by(by_sigil_name);
1404    // Topic variants sort numerically ($_, $_0, $_1, $_2 ...) — not lex
1405    // (which would put $_10 before $_2). Strip the `$_` prefix; an empty
1406    // tail (the bare `$_`) sorts before any digit suffix.
1407    // Order: `$_`, `$_0`, `$_1`, ..., `$a`, `$b`. Underscore family first
1408    // (sorted numerically so `$_2` precedes `$_10`), then sort/reduce
1409    // params last.
1410    topic.sort_by(|a, b| {
1411        let key = |n: &str| -> (u8, usize, String) {
1412            // Bucket 0 = underscore topics, bucket 1 = $a/$b.
1413            if n == "$a" || n == "$b" {
1414                (1, 0, n.to_string())
1415            } else {
1416                let bare = n.strip_prefix("$_").unwrap_or(n);
1417                (0, bare.parse::<usize>().unwrap_or(0), n.to_string())
1418            }
1419        };
1420        key(&a.name).cmp(&key(&b.name))
1421    });
1422    let mut out = user;
1423    out.extend(topic);
1424    out.extend(builtin);
1425    out
1426}
1427
1428/// Magic block-param scalars: `$_`, `$_0`, `$_1`, ... (topic + implicit
1429/// closure positionals) plus `$a` / `$b` (Perl-5 sort/reduce holdovers).
1430/// They belong below user-defined `my` variables but above compiler/runtime
1431/// builtins, so the Variables panel reads as: my-vars first, magic block
1432/// params in the middle, builtins at the bottom.
1433fn is_magic_block_param(name: &str) -> bool {
1434    if name == "_" || name == "a" || name == "b" {
1435        return true;
1436    }
1437    if let Some(rest) = name.strip_prefix('_') {
1438        return !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit());
1439    }
1440    false
1441}
1442
1443fn should_hide(name: &str) -> bool {
1444    if name.is_empty() {
1445        return true;
1446    }
1447    // Compiler-internal `__foreach_i__`, `__foreach_list__`,
1448    // `__INTERCEPT_NAME__`, `__INTERCEPT_ARGS__`, etc. — anything wrapped
1449    // in double underscores is reserved synthetic state, never user-facing.
1450    if name.starts_with("__") && name.ends_with("__") && name.len() > 4 {
1451        return true;
1452    }
1453    false
1454}
1455
1456/// True for compiler-generated / stryke-built-in names that should sort to
1457/// the bottom of the Variables panel so the user's own `my $foo` ones float
1458/// to the top.
1459fn is_builtin_like(name: &str) -> bool {
1460    if name.is_empty() {
1461        return false;
1462    }
1463    if name.starts_with('^') {
1464        return true;
1465    }
1466    // Pipeline outer-chain topic vars: `_<`, `_<<`, `_<<<`, `_0<`, etc.
1467    if name.starts_with('_') && name.len() > 1 && name[1..].contains('<') {
1468        return true;
1469    }
1470    // Common stryke built-in arrays/hashes
1471    matches!(
1472        name,
1473        "INC"
1474            | "ARGV"
1475            | "ENV"
1476            | "SIG"
1477            | "path"
1478            | "p"
1479            | "fpath"
1480            | "f"
1481            | "term"
1482            | "uname"
1483            | "limits"
1484            | "-"
1485            | "+"
1486            | "~"
1487            | "/"
1488            | "\\"
1489            | "\""
1490            | ","
1491            | "!"
1492            | "@"
1493            | "&"
1494            | "'"
1495            | "`"
1496            | "?"
1497            | "$"
1498    ) || name.starts_with("stryke::")
1499}
1500
1501// ─── Public entrypoint ──────────────────────────────────────────────────────
1502
1503/// Run `st --dap [HOST:PORT]`.
1504///
1505/// * **Stdio mode** (`st --dap`) — DAP traffic uses stdio. Fine for manual
1506///   shell testing; *broken* under IntelliJ because `OSProcessHandler` reads
1507///   the same stdout stream and steals bytes from `DapClient`.
1508/// * **TCP mode** (`st --dap HOST:PORT`) — connects to the given address and
1509///   runs DAP over that socket. Stdio is left alone so the program's `p` /
1510///   `print` output flows normally to the Console. This is the path the
1511///   IntelliJ plugin uses.
1512pub fn run() -> i32 {
1513    run_with_args(&[])
1514}
1515
1516pub fn run_with_args(args: &[String]) -> i32 {
1517    crate::slog_info!(
1518        "dap",
1519        "starting --dap pid={} args={:?} version={} log_level={:?}",
1520        std::process::id(),
1521        args,
1522        env!("CARGO_PKG_VERSION"),
1523        crate::stryke_log::current_level()
1524    );
1525    let connect_addr = args.iter().find(|a| a.contains(':')).cloned();
1526    let (reader, writer): (Box<dyn Read + Send>, Box<dyn Write + Send>) = match connect_addr {
1527        Some(addr) => {
1528            // TCP mode: connect to the spawner's server socket.
1529            match std::net::TcpStream::connect(&addr) {
1530                Ok(s) => {
1531                    crate::slog_info!("dap", "connected tcp {}", addr);
1532                    let r = s.try_clone().expect("dap: tcp clone");
1533                    (Box::new(r), Box::new(s))
1534                }
1535                Err(e) => {
1536                    crate::slog_error!("dap", "tcp connect {} failed: {}", addr, e);
1537                    eprintln!("stryke --dap: connect {addr}: {e}");
1538                    return 2;
1539                }
1540            }
1541        }
1542        None => {
1543            crate::slog_info!("dap", "stdio mode");
1544            (Box::new(io::stdin()), Box::new(io::stdout()))
1545        }
1546    };
1547
1548    let shared = DapShared::new(writer);
1549    let bp_state = Arc::new(Mutex::new(BreakpointState::default()));
1550    let (_reader_handle, launch_rx) =
1551        spawn_reader_with_input(shared.clone(), bp_state.clone(), reader);
1552
1553    // Wait for `launch` request before starting the VM.
1554    let lp = match launch_rx.recv() {
1555        Ok(p) => p,
1556        Err(_) => return 1,
1557    };
1558
1559    // Send `process` event for prettiness in client UIs.
1560    shared.emit_event(
1561        "process",
1562        json!({
1563            "name": lp.program,
1564            "isLocalProcess": true,
1565            "startMethod": "launch",
1566        }),
1567    );
1568    shared.emit_event("thread", json!({ "reason": "started", "threadId": 1 }));
1569
1570    // Read the program source.
1571    let source = match std::fs::read_to_string(&lp.program) {
1572        Ok(s) => s,
1573        Err(e) => {
1574            shared.emit_event(
1575                "output",
1576                json!({ "category": "stderr", "output": format!("stryke --dap: cannot read {}: {}\n", lp.program, e) }),
1577            );
1578            shared.emit_event("terminated", json!({}));
1579            return 1;
1580        }
1581    };
1582
1583    // Build interpreter + debugger.
1584    let mut interp = crate::vm_helper::VMHelper::new();
1585    if let Some(cwd) = &lp.cwd {
1586        let _ = std::env::set_current_dir(cwd);
1587    }
1588    interp.file = lp.program.clone();
1589    // Pre-populate @ARGV
1590    let argv_vals: Vec<crate::value::StrykeValue> = lp
1591        .args
1592        .iter()
1593        .map(|s| crate::value::StrykeValue::string(s.clone()))
1594        .collect();
1595    interp.scope.declare_array("ARGV", argv_vals);
1596
1597    // Pre-populate @INC the same way `configure_interpreter` does for CLI:
1598    // vendor perl modules, system perl's INC, the script's directory,
1599    // `STRYKE_INC`, then ".".
1600    let mut inc_paths: Vec<String> = Vec::new();
1601    let vendor = crate::vendor_perl_inc_path();
1602    if vendor.is_dir() {
1603        crate::perl_inc::push_unique_string_paths(
1604            &mut inc_paths,
1605            vec![vendor.to_string_lossy().into_owned()],
1606        );
1607    }
1608    crate::perl_inc::push_unique_string_paths(
1609        &mut inc_paths,
1610        crate::perl_inc::paths_from_system_perl(),
1611    );
1612    if let Some(parent) = std::path::Path::new(&lp.program).parent() {
1613        if !parent.as_os_str().is_empty() {
1614            crate::perl_inc::push_unique_string_paths(
1615                &mut inc_paths,
1616                vec![parent.to_string_lossy().into_owned()],
1617            );
1618        }
1619    }
1620    if let Ok(extra) = std::env::var("STRYKE_INC") {
1621        let extra: Vec<String> = std::env::split_paths(&extra)
1622            .map(|p| p.to_string_lossy().into_owned())
1623            .collect();
1624        crate::perl_inc::push_unique_string_paths(&mut inc_paths, extra);
1625    }
1626    crate::perl_inc::push_unique_string_paths(&mut inc_paths, vec![".".to_string()]);
1627    let inc_dirs: Vec<crate::value::StrykeValue> = inc_paths
1628        .into_iter()
1629        .map(crate::value::StrykeValue::string)
1630        .collect();
1631    interp.scope.declare_array("INC", inc_dirs);
1632
1633    // Eagerly populate %ENV so the Variables panel shows the process env on
1634    // first stop. Normal CLI mode defers this to first `$ENV{KEY}` access;
1635    // for an inspect-only debugger surface we want it visible immediately.
1636    interp.materialize_env_if_needed();
1637
1638    // Force `$| = 1` so user `p` / `print` / `printf` calls flush stdout
1639    // immediately. Without this, stdout is block-buffered (piped to the
1640    // IDE's OSProcessHandler) and output doesn't appear in the Console
1641    // until the buffer fills or the program exits. Real-time tracing
1642    // through a debugger needs immediate output.
1643    interp.output_autoflush = true;
1644
1645    // NOTE: NOT calling `ensure_reflection_hashes()` here. Doing so triggers
1646    // a stack overflow during the VM's main dispatch (probably some path in
1647    // hash-lookup recursion when 10k+ entries are eagerly inserted). The
1648    // hashes get populated lazily on first user access via the
1649    // `touch_env_hash` hook in vm_helper, so they'll be visible *after* the
1650    // script accesses one. Eagerly installing them needs more investigation.
1651
1652    // Configure debugger with DAP backend.
1653    let mut dbg = crate::debugger::Debugger::new();
1654    dbg.set_file(&lp.program);
1655    dbg.load_source(&source);
1656    // Pre-set breakpoints
1657    {
1658        let bp = bp_state.lock().expect("bp lock");
1659        if let Some(lines) = bp.line_breakpoints.get(&lp.program) {
1660            for l in lines {
1661                dbg.add_breakpoint_line(*l);
1662            }
1663        }
1664        for name in &bp.function_breakpoints {
1665            dbg.add_breakpoint_sub(name);
1666        }
1667    }
1668    dbg.set_dap_backend(shared.clone(), bp_state.clone());
1669    if !lp.stop_on_entry {
1670        dbg.set_step_mode(false);
1671    }
1672    interp.debugger = Some(dbg);
1673
1674    // Parse + run. `no_interop` flag is exposed but we always go through the
1675    // standard parser in v1; honoring the flag is a follow-on once
1676    // `parse_with_file_no_interop` is exposed publicly.
1677    let _ = lp.no_interop;
1678    let program = match crate::parse_with_file(&source, &lp.program) {
1679        Ok(p) => p,
1680        Err(e) => {
1681            shared.emit_event(
1682                "output",
1683                json!({
1684                    "category": "stderr",
1685                    "output": format!("stryke: parse error: {}\n", e.message),
1686                }),
1687            );
1688            shared.emit_event(
1689                "stopped",
1690                json!({ "reason": "exception", "threadId": 1, "description": e.message }),
1691            );
1692            shared.emit_event("terminated", json!({}));
1693            return 1;
1694        }
1695    };
1696
1697    let result = interp.execute(&program);
1698
1699    let exit_code = match result {
1700        Ok(_) => 0,
1701        Err(e) => {
1702            // Client-initiated disconnect propagates back as "debugger: quit".
1703            // That's expected shutdown, not a user-visible runtime error.
1704            if e.message != "debugger: quit" && !shared.was_disconnected() {
1705                shared.emit_event(
1706                    "output",
1707                    json!({
1708                        "category": "stderr",
1709                        "output": format!("stryke: runtime error: {}\n", e.message),
1710                    }),
1711                );
1712            }
1713            if shared.was_disconnected() {
1714                0
1715            } else {
1716                1
1717            }
1718        }
1719    };
1720
1721    shared.emit_event("exited", json!({ "exitCode": exit_code }));
1722    shared.emit_event("terminated", json!({}));
1723    exit_code
1724}
1725
1726#[cfg(test)]
1727mod tests {
1728    use super::*;
1729
1730    // ── is_magic_block_param classifier ─────────────────────────────────
1731    //
1732    // Drives the Variables panel's three-tier sort: user `my` vars first,
1733    // then magic block params (`$_`, `$_N`, `$a`, `$b`), then builtins.
1734    // Misclassifying a user variable as magic would silently push it to
1735    // the wrong bucket.
1736
1737    #[test]
1738    fn magic_block_param_matches_underscore_topic_and_sort_pair() {
1739        assert!(is_magic_block_param("_"), "$_ topic");
1740        assert!(is_magic_block_param("_0"), "$_0 first positional");
1741        assert!(is_magic_block_param("_1"), "$_1 second positional");
1742        assert!(is_magic_block_param("_42"), "$_N N-th positional");
1743        assert!(is_magic_block_param("a"), "$a sort/reduce");
1744        assert!(is_magic_block_param("b"), "$b sort/reduce");
1745    }
1746
1747    #[test]
1748    fn magic_block_param_rejects_user_names() {
1749        assert!(!is_magic_block_param("name"));
1750        assert!(
1751            !is_magic_block_param("_name"),
1752            "underscore-prefix is not a topic alias"
1753        );
1754        assert!(!is_magic_block_param("a1"));
1755        assert!(
1756            !is_magic_block_param("ab"),
1757            "$ab is a user var, not a magic block param"
1758        );
1759        assert!(!is_magic_block_param(""));
1760    }
1761
1762    // ── should_hide — synthetic compiler vars ───────────────────────────
1763    //
1764    // The Variables panel hides anything wrapped in double underscores
1765    // (`__foreach_i__`, `__INTERCEPT_NAME__`, etc.) — those are compiler-
1766    // internal synthetic names, never user-facing. The check is "starts
1767    // and ends with `__` AND length > 4" so we don't accidentally hide
1768    // `__` itself or `__x__` style user names that happen to have the
1769    // marker shape.
1770
1771    #[test]
1772    fn should_hide_dunder_synthetic_names() {
1773        assert!(should_hide("__foreach_i__"));
1774        assert!(should_hide("__INTERCEPT_NAME__"));
1775        assert!(should_hide("__list_assign_tmp__"));
1776    }
1777
1778    #[test]
1779    fn should_hide_keeps_user_visible_names() {
1780        assert!(!should_hide("x"));
1781        assert!(!should_hide("my_var"));
1782        assert!(!should_hide("_"));
1783        assert!(!should_hide("_0"));
1784        assert!(!should_hide("__"));
1785        // Single-leading-underscore is a user var (per
1786        // is_magic_block_param), not a synthetic.
1787        assert!(!should_hide("_foo"));
1788    }
1789
1790    #[test]
1791    fn should_hide_empty_name() {
1792        assert!(should_hide(""));
1793    }
1794
1795    // ── fmt_f — number formatting for sketch panel rows ─────────────────
1796
1797    #[test]
1798    fn fmt_f_trims_trailing_zeros() {
1799        assert_eq!(fmt_f(1.0), "1");
1800        assert_eq!(fmt_f(1.5), "1.5");
1801        assert_eq!(fmt_f(0.0), "0");
1802        assert_eq!(fmt_f(-2.5), "-2.5");
1803    }
1804
1805    #[test]
1806    fn fmt_f_uses_scientific_for_extremes() {
1807        // Very small.
1808        assert!(fmt_f(1e-10).contains('e'));
1809        // Very large.
1810        assert!(fmt_f(1e20).contains('e'));
1811    }
1812
1813    #[test]
1814    fn fmt_f_handles_non_finite() {
1815        // NaN / inf round-trip through Rust's default Display.
1816        assert_eq!(fmt_f(f64::NAN), "NaN");
1817        assert_eq!(fmt_f(f64::INFINITY), "inf");
1818    }
1819
1820    // ── is_builtin_like — bottom-bucket sort classifier ─────────────────
1821
1822    #[test]
1823    fn is_builtin_like_matches_stryke_builtin_arrays_and_hashes() {
1824        assert!(is_builtin_like("INC"));
1825        assert!(is_builtin_like("ARGV"));
1826        assert!(is_builtin_like("ENV"));
1827        assert!(is_builtin_like("path"));
1828        assert!(is_builtin_like("p"));
1829        assert!(is_builtin_like("term"));
1830    }
1831
1832    #[test]
1833    fn is_builtin_like_matches_caret_prefixed_specials() {
1834        // `$^O`, `$^X`, etc. — the `^` prefix marks them as Perl special
1835        // variables visible only via the caret-name form.
1836        assert!(is_builtin_like("^O"));
1837        assert!(is_builtin_like("^X"));
1838        assert!(is_builtin_like("^HOOK"));
1839    }
1840
1841    #[test]
1842    fn is_builtin_like_matches_pipeline_outer_topic_chains() {
1843        // `_<`, `_<<`, `_0<`, ... — outer-topic chain naming.
1844        assert!(is_builtin_like("_<"));
1845        assert!(is_builtin_like("_<<"));
1846        assert!(is_builtin_like("_0<"));
1847        assert!(is_builtin_like("_5<<<"));
1848    }
1849
1850    #[test]
1851    fn is_builtin_like_rejects_plain_user_names() {
1852        assert!(!is_builtin_like("x"));
1853        assert!(!is_builtin_like("my_var"));
1854        assert!(!is_builtin_like("_5"));
1855        assert!(!is_builtin_like(""));
1856    }
1857
1858    // ── try_sketch_child — Variables-panel drill-down ───────────────────
1859    //
1860    // The Variables panel renders sketches and user-defined types with a
1861    // one-line summary repr plus an expandable `var_ref` holding labelled
1862    // sub-rows (count / min / max / p99 / per-field / per-element).
1863    // These tests build the underlying values directly and inspect the
1864    // emitted VarChild to pin the drill-down contract.
1865
1866    use std::sync::Arc;
1867    // `Mutex` here is the parking_lot variant the sketch types use — std::sync::Mutex
1868    // has a different signature (returns LockResult) and the wrong inner type.
1869    use parking_lot::Mutex;
1870
1871    fn drill(value: &crate::value::StrykeValue) -> (VarChild, HashMap<u32, Vec<VarChild>>) {
1872        let mut map: HashMap<u32, Vec<VarChild>> = HashMap::new();
1873        let mut ctx = CaptureCtx {
1874            next_ref: CONTAINER_REF_BASE,
1875            map: &mut map,
1876        };
1877        let child =
1878            try_sketch_child("$v", value, 0, &mut ctx).expect("sketch drill yields VarChild");
1879        (child, map)
1880    }
1881
1882    #[test]
1883    fn drill_tdigest_empty_summarises_compression() {
1884        let arc = Arc::new(Mutex::new(crate::sketches::TDigestSketch::new(100)));
1885        let v = crate::value::StrykeValue::tdigest_sketch(arc);
1886        let (row, map) = drill(&v);
1887        assert!(row.repr.contains("TDigestSketch"), "type tag in repr");
1888        assert!(row.repr.contains("empty"), "empty marker shown");
1889        let kids = map.get(&row.var_ref).expect("expandable");
1890        let names: Vec<&str> = kids.iter().map(|c| c.name.as_str()).collect();
1891        assert!(names.contains(&"count"), "count row");
1892        assert!(names.contains(&"compression"), "compression row");
1893    }
1894
1895    #[test]
1896    fn drill_tdigest_populated_emits_quantile_rows() {
1897        let s = crate::sketches::TDigestSketch::new(100);
1898        let arc = Arc::new(Mutex::new(s));
1899        // Feed a small distribution.
1900        {
1901            let mut g = arc.lock();
1902            for i in 1..=100 {
1903                g.add(i as f64);
1904            }
1905        }
1906        let v = crate::value::StrykeValue::tdigest_sketch(arc);
1907        let (row, map) = drill(&v);
1908        let kids = map.get(&row.var_ref).expect("expandable");
1909        let names: Vec<&str> = kids.iter().map(|c| c.name.as_str()).collect();
1910        for p in [
1911            "count", "min", "max", "mean", "sum", "p50", "p90", "p95", "p99",
1912        ] {
1913            assert!(names.contains(&p), "{p} row present in {names:?}");
1914        }
1915        // Inline repr captures n / min / max / p50 / p99.
1916        assert!(
1917            row.repr.contains("n=100"),
1918            "count in inline repr: {}",
1919            row.repr
1920        );
1921        assert!(
1922            row.repr.contains("min="),
1923            "min in inline repr: {}",
1924            row.repr
1925        );
1926        assert!(
1927            row.repr.contains("p99="),
1928            "p99 in inline repr: {}",
1929            row.repr
1930        );
1931    }
1932
1933    #[test]
1934    fn drill_bloom_filter_exposes_inserted_bits_k_fpr() {
1935        let mut bf = crate::sketches::BloomFilter::new(1000, 0.01);
1936        bf.add(b"hello");
1937        bf.add(b"world");
1938        let v = crate::value::StrykeValue::bloom_filter(Arc::new(Mutex::new(bf)));
1939        let (row, map) = drill(&v);
1940        assert!(
1941            row.repr.starts_with("BloomFilter("),
1942            "type tag: {}",
1943            row.repr
1944        );
1945        let kids = map.get(&row.var_ref).expect("expandable");
1946        let names: Vec<&str> = kids.iter().map(|c| c.name.as_str()).collect();
1947        for f in ["inserted", "bit_count", "k", "estimated_fpr"] {
1948            assert!(names.contains(&f), "{f} row present in {names:?}");
1949        }
1950    }
1951
1952    #[test]
1953    fn drill_hll_exposes_cardinality_precision_registers() {
1954        let mut h = crate::sketches::HllSketch::new(12);
1955        for i in 0..50u32 {
1956            h.add(&i.to_le_bytes());
1957        }
1958        let v = crate::value::StrykeValue::hll_sketch(Arc::new(Mutex::new(h)));
1959        let (row, map) = drill(&v);
1960        assert!(row.repr.starts_with("HllSketch("), "type tag: {}", row.repr);
1961        let kids = map.get(&row.var_ref).expect("expandable");
1962        let names: Vec<&str> = kids.iter().map(|c| c.name.as_str()).collect();
1963        for f in ["cardinality", "precision", "registers"] {
1964            assert!(names.contains(&f), "{f} row present in {names:?}");
1965        }
1966    }
1967
1968    #[test]
1969    fn drill_non_sketch_returns_none() {
1970        // A plain integer is not a sketch / struct / class / set —
1971        // try_sketch_child must defer to the generic scalar path.
1972        let v = crate::value::StrykeValue::integer(42);
1973        let mut map: HashMap<u32, Vec<VarChild>> = HashMap::new();
1974        let mut ctx = CaptureCtx {
1975            next_ref: CONTAINER_REF_BASE,
1976            map: &mut map,
1977        };
1978        let child = try_sketch_child("$v", &v, 0, &mut ctx);
1979        assert!(child.is_none(), "non-sketch returns None");
1980    }
1981}