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