Skip to main content

zsh/extensions/
dap.rs

1//! DAP server for zshrs — `zshrs --dap [HOST:PORT]` (TCP connect-back, or stdio
2//! when no address is given).
3//!
4//! Mirrors strykelang's DAP architecture (`strykelang/dap.rs`):
5//! connect TCP → spawn reader thread → wait for `launch` → run the
6//! script IN-PROCESS so per-statement breakpoint checks have a live
7//! interpreter to pause. The compiler emits `BUILTIN_SET_LINENO` at
8//! every top-level statement; that builtin's handler in
9//! `fusevm_bridge.rs` calls [`check_line`] which consults the
10//! private `DAP_SHARED` static for matching breakpoints and
11//! condvar-waits in [`DapShared::pause`].
12//!
13//! NOT subprocess-based — earlier v1 spawned the script as a child,
14//! which made it impossible to honor breakpoints (no IPC channel to
15//! pause the child). The new model keeps the script in-process so the
16//! cv-wait literally blocks the executor thread until the IDE sends
17//! `continue`.
18
19use serde_json::{json, Value};
20use std::collections::HashMap;
21use std::io::{self, BufRead, BufReader, Read, Write};
22use std::net::TcpStream;
23use std::process::{Child, ChildStdout, Command, Stdio};
24use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
25use std::sync::{Arc, Condvar, Mutex, OnceLock};
26use std::thread;
27use std::time::Duration;
28
29// ── Pause-and-resume shared state ───────────────────────────────────────
30
31/// What the IDE asked the paused executor to do.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum DebugAction {
34    /// Resume normal execution.
35    Continue,
36    /// Step over the current statement (treat as Continue + pause at
37    /// the next `check_line` call). v1 simplification — Continue is
38    /// the same as StepOver in absence of frame depth tracking.
39    StepOver,
40    /// Step in — same simplification as StepOver in v1.
41    StepIn,
42    /// Step out — same simplification as Continue in v1.
43    StepOut,
44    /// Client disconnected — bail out of the script.
45    Quit,
46}
47
48/// Snapshot captured when the executor pauses. Sent back to the IDE
49/// as part of the `stopped` event body.
50#[derive(Debug, Clone, Default)]
51pub struct PauseSnapshot {
52    pub reason: String, // "breakpoint" | "step" | "pause" | "entry"
53    /// `file` field.
54    pub file: String,
55    /// `line` field.
56    pub line: u32,
57}
58
59/// Breakpoint state shared between the reader thread (which updates
60/// it on `setBreakpoints`) and the executor thread (which consults
61/// it on every `check_line`).
62#[derive(Debug, Default)]
63pub struct BreakpointState {
64    /// Absolute file path → set of 1-based line numbers.
65    pub line_breakpoints: HashMap<String, Vec<u32>>,
66}
67
68struct DapSharedInner {
69    pending_action: Option<DebugAction>,
70    is_paused: bool,
71    pause_request: bool, // client asked us to pause asap (via `pause`)
72    step_mode: bool,     // true = pause at every check_line (StepOver/In)
73}
74
75/// Reader thread + executor thread coordinate through this. Owns the
76/// TCP writer, the request seq counter, the pause-cv, and the
77/// disconnected flag.
78pub struct DapShared {
79    /// `inner` field.
80    inner: Mutex<DapSharedInner>,
81    /// `cv` field.
82    cv: Condvar,
83    /// `seq` field.
84    seq: AtomicU64,
85    /// `writer` field — the DAP output sink (a TCP socket in connect-back mode,
86    /// stdout in stdio mode).
87    writer: Mutex<Box<dyn Write + Send>>,
88    /// `configuration_done` field.
89    pub configuration_done: AtomicBool,
90    /// `disconnected` field.
91    pub disconnected: AtomicBool,
92    /// Absolute path of the launched program — set on `launch`, read
93    /// by `check_line` to know which file's breakpoint set to check.
94    pub program: Mutex<String>,
95}
96
97impl DapShared {
98    fn new(writer: Box<dyn Write + Send>) -> Arc<Self> {
99        Arc::new(Self {
100            inner: Mutex::new(DapSharedInner {
101                pending_action: None,
102                is_paused: false,
103                pause_request: false,
104                step_mode: false,
105            }),
106            cv: Condvar::new(),
107            seq: AtomicU64::new(1),
108            writer: Mutex::new(writer),
109            configuration_done: AtomicBool::new(false),
110            disconnected: AtomicBool::new(false),
111            program: Mutex::new(String::new()),
112        })
113    }
114
115    /// Called by the executor thread when a `check_line` matches a
116    /// breakpoint (or step mode is on). Captures the snapshot, emits
117    /// a `stopped` event, then condvar-waits for the IDE to send
118    /// `continue` / `next` / `stepIn` / `stepOut`.
119    pub fn pause(&self, snap: PauseSnapshot) -> DebugAction {
120        // Flush stdout/stderr so any `echo` / `print` output the user
121        // produced before this breakpoint is visible in the IDE
122        // Console BEFORE the suspend UI shows.
123        let _ = io::Write::flush(&mut io::stdout());
124        let _ = io::Write::flush(&mut io::stderr());
125        tracing::info!(
126            target: "zshrs::dap::pause",
127            reason = %snap.reason,
128            file = %snap.file,
129            line = snap.line,
130            "executor PAUSED (cv-wait)",
131        );
132        {
133            let mut s = self.inner.lock().expect("dap lock");
134            s.is_paused = true;
135            s.pending_action = None;
136            s.pause_request = false;
137        }
138        let _ = self.emit_event(
139            "stopped",
140            json!({
141                "reason": snap.reason,
142                "threadId": 1,
143                "allThreadsStopped": true,
144                "preserveFocusHint": false,
145                "description": snap.reason,
146                "text": format!("{}:{}", snap.file, snap.line),
147            }),
148        );
149        let mut guard = self.inner.lock().expect("dap lock");
150        while guard.pending_action.is_none() && !self.disconnected.load(Ordering::SeqCst) {
151            guard = self.cv.wait(guard).expect("dap cv");
152        }
153        let action = guard.pending_action.take().unwrap_or(DebugAction::Continue);
154        guard.is_paused = false;
155        // Step mode persists until the IDE sends a plain `continue`.
156        guard.step_mode = matches!(action, DebugAction::StepOver | DebugAction::StepIn);
157        tracing::info!(
158            target: "zshrs::dap::pause",
159            ?action,
160            step_mode = guard.step_mode,
161            "executor RESUMED",
162        );
163        action
164    }
165
166    fn resume_with(&self, action: DebugAction) {
167        let mut g = self.inner.lock().expect("dap lock");
168        g.pending_action = Some(action);
169        self.cv.notify_all();
170    }
171
172    fn request_pause(&self) {
173        let mut g = self.inner.lock().expect("dap lock");
174        g.pause_request = true;
175    }
176
177    fn want_pause(&self) -> bool {
178        self.inner.lock().map(|g| g.pause_request).unwrap_or(false)
179    }
180
181    fn step_mode(&self) -> bool {
182        self.inner.lock().map(|g| g.step_mode).unwrap_or(false)
183    }
184
185    fn next_seq(&self) -> u64 {
186        self.seq.fetch_add(1, Ordering::SeqCst)
187    }
188
189    fn write_message(&self, body: Value) -> io::Result<()> {
190        let s = serde_json::to_string(&body)?;
191        let mut w = self.writer.lock().expect("dap writer");
192        write!(w, "Content-Length: {}\r\n\r\n{}", s.len(), s)?;
193        w.flush()
194    }
195
196    fn emit_response(
197        &self,
198        req_seq: i64,
199        command: &str,
200        success: bool,
201        body: Value,
202    ) -> io::Result<()> {
203        let seq = self.next_seq();
204        let msg = json!({
205            "seq": seq,
206            "type": "response",
207            "request_seq": req_seq,
208            "command": command,
209            "success": success,
210            "body": body,
211        });
212        tracing::trace!(target: "zshrs::dap::send", seq, %command, "response");
213        self.write_message(msg)
214    }
215    /// `emit_event` — see implementation.
216    pub fn emit_event(&self, event: &str, body: Value) -> io::Result<()> {
217        let seq = self.next_seq();
218        let milestone = matches!(
219            event,
220            "stopped" | "terminated" | "exited" | "initialized" | "process" | "breakpoint"
221        );
222        if milestone {
223            tracing::info!(target: "zshrs::dap::send", seq, %event, "event (milestone)");
224        } else {
225            tracing::trace!(target: "zshrs::dap::send", seq, %event, "event");
226        }
227        let msg = json!({
228            "seq": seq,
229            "type": "event",
230            "event": event,
231            "body": body,
232        });
233        self.write_message(msg)
234    }
235}
236
237/// Global handle to the live DAP server (set by `run_dap` before the
238/// script starts). The `BUILTIN_SET_LINENO` handler in
239/// `fusevm_bridge.rs` reads this on every statement; if unset
240/// (normal shell mode) the lookup is a single atomic load and falls
241/// through to no-op.
242static DAP_SHARED: OnceLock<Arc<DapShared>> = OnceLock::new();
243
244/// Per-thread breakpoint snapshot — read in the hot path of every
245/// `check_line` call. Cloned from `BreakpointState` after each
246/// `setBreakpoints` so the executor doesn't need to lock the global
247/// state on every statement.
248static DAP_BREAKPOINTS: OnceLock<Arc<Mutex<BreakpointState>>> = OnceLock::new();
249
250/// Called by `BUILTIN_SET_LINENO` in `fusevm_bridge.rs` for every
251/// top-level statement. O(1) when DAP is not active (single atomic
252/// load). When active, checks the breakpoint set for the launched
253/// program and pauses if the current line matches OR if step mode
254/// is on OR if the client asked for a pause.
255pub fn check_line(line: u32) {
256    let Some(shared) = DAP_SHARED.get() else {
257        return;
258    };
259    if shared.disconnected.load(Ordering::SeqCst) {
260        return;
261    }
262    let program = shared.program.lock().map(|g| g.clone()).unwrap_or_default();
263    let reason = if shared.want_pause() {
264        "pause"
265    } else if shared.step_mode() {
266        "step"
267    } else {
268        // Breakpoint match check
269        let bp_arc = match DAP_BREAKPOINTS.get() {
270            Some(b) => b,
271            None => return,
272        };
273        let bp = match bp_arc.lock() {
274            Ok(g) => g,
275            Err(_) => return,
276        };
277        let hit = bp
278            .line_breakpoints
279            .get(&program)
280            .map(|lines| lines.contains(&line))
281            .unwrap_or(false);
282        if !hit {
283            return;
284        }
285        "breakpoint"
286    };
287    let snap = PauseSnapshot {
288        reason: reason.to_string(),
289        file: program,
290        line,
291    };
292    let action = shared.pause(snap);
293    if matches!(action, DebugAction::Quit) {
294        // Terminate the script. We don't have a clean unwind from
295        // inside the VM here; signal via errflag so the executor
296        // halts at the next safe point.
297        crate::ported::utils::errflag
298            .fetch_or(crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed);
299    }
300}
301
302// ── Public entry point ──────────────────────────────────────────────────
303
304/// Serve the DAP protocol until the client disconnects.
305///
306/// `addr` selects the transport (called from `bins/zshrs.rs` on `--dap`):
307///   * `Some("127.0.0.1:55123")` — **TCP connect-back**: dial the IDE's DAP
308///     listener (the path JetBrains uses; stdout stays free for the script).
309///   * `None` — **stdio**: DAP over stdin/stdout, for clients that spawn the
310///     adapter as an executable (e.g. VS Code). Unusable under IntelliJ, whose
311///     OSProcessHandler reads stdout and would steal DAP bytes.
312///
313/// Stryke-mirror architecture:
314///   1. Connect the transport (TCP socket or stdin/stdout).
315///   2. Spawn reader thread to handle `initialize`, `setBreakpoints`,
316///      `launch`, `continue`, etc. Each `launch` request goes through
317///      a oneshot channel back to the main thread.
318///   3. Block on the channel until `launch` arrives.
319///   4. Install DAP_SHARED + DAP_BREAKPOINTS globals (the BUILTIN_SET_LINENO
320///      hook in fusevm_bridge.rs consults them on every statement).
321///   5. Run the script IN-PROCESS via `ShellExecutor::execute_script_file`.
322///      The executor blocks inside `check_line → pause()` whenever a
323///      breakpoint hits; the reader thread sends `continue` to resume.
324///   6. After execution: emit `exited` + `terminated`, drop globals.
325pub fn run_dap(addr: Option<&str>) -> i32 {
326    tracing::info!(
327        target: "zshrs::dap",
328        pid = std::process::id(),
329        mode = if addr.is_some() { "tcp" } else { "stdio" },
330        "starting --dap",
331    );
332
333    // Two transports. Both run the identical DAP server below — only the
334    // reader/writer differ.
335    let (reader, writer): (Box<dyn Read + Send>, Box<dyn Write + Send>) = match addr {
336        // TCP connect-back: dial the IDE's DAP listener (the path JetBrains
337        // uses). stdin/stdout are left free so the script's own output reaches
338        // the console.
339        Some(addr) => {
340            let stream = match TcpStream::connect(addr) {
341                Ok(s) => s,
342                Err(e) => {
343                    tracing::error!(target: "zshrs::dap", %addr, %e, "tcp connect failed");
344                    eprintln!("zshrs: --dap: connect {} failed: {}", addr, e);
345                    return 1;
346                }
347            };
348            if let Err(e) = stream.set_nodelay(true) {
349                tracing::warn!(target: "zshrs::dap", %e, "TCP_NODELAY failed (non-fatal)");
350            }
351            let reader_stream = match stream.try_clone() {
352                Ok(s) => s,
353                Err(e) => {
354                    tracing::error!(target: "zshrs::dap", %e, "tcp clone failed");
355                    eprintln!("zshrs: --dap: clone socket: {}", e);
356                    return 1;
357                }
358            };
359            tracing::info!(target: "zshrs::dap", %addr, "tcp connected");
360            (Box::new(reader_stream), Box::new(stream))
361        }
362        // Stdio mode: DAP traffic over stdin/stdout. For clients that spawn the
363        // adapter as an executable and talk over its pipes (e.g. VS Code's
364        // DebugAdapterExecutable). NOTE: unusable under IntelliJ, whose
365        // OSProcessHandler reads stdout and would steal DAP bytes — use TCP there.
366        None => {
367            tracing::info!(target: "zshrs::dap", "stdio mode");
368            (Box::new(io::stdin()), Box::new(io::stdout()))
369        }
370    };
371
372    let shared = DapShared::new(writer);
373    let bp_state = Arc::new(Mutex::new(BreakpointState::default()));
374
375    // Reader thread: parses DAP requests + dispatches. Sends a
376    // LaunchParams down the channel when `launch` arrives.
377    let (launch_tx, launch_rx) = std::sync::mpsc::channel::<LaunchParams>();
378    let shared_reader = shared.clone();
379    let bp_reader = bp_state.clone();
380    let _reader = thread::spawn(move || {
381        let mut br = BufReader::new(reader);
382        loop {
383            let msg = match read_message(&mut br) {
384                Ok(Some(m)) => m,
385                Ok(None) => {
386                    tracing::info!(target: "zshrs::dap", "client disconnected (EOF)");
387                    break;
388                }
389                Err(e) => {
390                    tracing::error!(target: "zshrs::dap", %e, "read error");
391                    break;
392                }
393            };
394            handle_request(&shared_reader, &bp_reader, &launch_tx, msg);
395            if shared_reader.disconnected.load(Ordering::SeqCst) {
396                break;
397            }
398        }
399        // Reader exiting — unblock any cv-wait so the executor can
400        // see Quit and bail.
401        shared_reader.disconnected.store(true, Ordering::SeqCst);
402        shared_reader.resume_with(DebugAction::Quit);
403    });
404
405    // Block until the IDE sends `launch`.
406    let lp = match launch_rx.recv() {
407        Ok(p) => p,
408        Err(_) => {
409            tracing::warn!(target: "zshrs::dap", "no launch received before disconnect");
410            return 1;
411        }
412    };
413    tracing::info!(
414        target: "zshrs::dap",
415        program = %lp.program,
416        cwd = ?lp.cwd,
417        args = ?lp.args,
418        stop_on_entry = lp.stop_on_entry,
419        "launch received",
420    );
421
422    // Install global hooks for BUILTIN_SET_LINENO.
423    *shared.program.lock().expect("program lock") = lp.program.clone();
424    let _ = DAP_SHARED.set(shared.clone());
425    let _ = DAP_BREAKPOINTS.set(bp_state.clone());
426    if lp.stop_on_entry {
427        // Force a pause at the first statement.
428        shared.inner.lock().expect("dap lock").step_mode = true;
429    }
430
431    // Cosmetic events for IDE UI.
432    let _ = shared.emit_event(
433        "process",
434        json!({
435            "name": lp.program,
436            "systemProcessId": std::process::id(),
437            "isLocalProcess": true,
438            "startMethod": "launch",
439        }),
440    );
441    let _ = shared.emit_event("thread", json!({ "reason": "started", "threadId": 1 }));
442
443    if let Some(cwd) = &lp.cwd {
444        let _ = std::env::set_current_dir(cwd);
445    }
446
447    // Run the script in-process. The BUILTIN_SET_LINENO hook will
448    // block on `shared.pause()` whenever a breakpoint matches.
449    tracing::info!(target: "zshrs::dap", "entering executor (in-process)");
450    let mut exec = crate::vm_helper::ShellExecutor::new();
451    let exit_code = match exec.execute_script_file(&lp.program) {
452        Ok(status) => status,
453        Err(e) => {
454            tracing::error!(target: "zshrs::dap", %e, "executor returned error");
455            let _ = shared.emit_event(
456                "output",
457                json!({
458                    "category": "stderr",
459                    "output": format!("zshrs --dap: {}\n", e),
460                }),
461            );
462            1
463        }
464    };
465    tracing::info!(target: "zshrs::dap", exit_code, "executor exited");
466
467    let _ = shared.emit_event("exited", json!({ "exitCode": exit_code }));
468    let _ = shared.emit_event("terminated", json!({}));
469    let _ = shared.emit_event("thread", json!({ "reason": "exited", "threadId": 1 }));
470    // Brief grace period for the writer to drain before the process exits.
471    thread::sleep(Duration::from_millis(50));
472    exit_code
473}
474
475#[derive(Debug, Clone)]
476struct LaunchParams {
477    program: String,
478    cwd: Option<String>,
479    args: Vec<String>,
480    stop_on_entry: bool,
481}
482
483/// Dispatch a single DAP request. `launch_tx` is used to hand the
484/// program info back to the main thread; everything else (breakpoints,
485/// continue, etc.) is handled here directly.
486fn handle_request(
487    shared: &Arc<DapShared>,
488    bp_state: &Arc<Mutex<BreakpointState>>,
489    launch_tx: &std::sync::mpsc::Sender<LaunchParams>,
490    msg: Value,
491) {
492    let cmd = msg
493        .get("command")
494        .and_then(|v| v.as_str())
495        .unwrap_or("")
496        .to_string();
497    let req_seq = msg.get("seq").and_then(|v| v.as_i64()).unwrap_or(0);
498    let args = msg.get("arguments").cloned().unwrap_or(Value::Null);
499    tracing::trace!(
500        target: "zshrs::dap::recv",
501        seq = req_seq,
502        %cmd,
503        "request",
504    );
505
506    match cmd.as_str() {
507        "initialize" => {
508            let _ = shared.emit_response(
509                req_seq,
510                &cmd,
511                true,
512                json!({
513                    "supportsConfigurationDoneRequest": true,
514                    "supportsEvaluateForHovers": true,
515                    "supportsTerminateRequest": true,
516                    "supportsStepBack": false,
517                    "supportsSetVariable": false,
518                    "supportsConditionalBreakpoints": false,
519                    "supportsHitConditionalBreakpoints": false,
520                    "supportsFunctionBreakpoints": false,
521                    "supportsRestartFrame": false,
522                    "supportsGotoTargetsRequest": false,
523                    "supportsStepInTargetsRequest": false,
524                    "supportsCompletionsRequest": false,
525                    "supportsModulesRequest": false,
526                    "supportsExceptionInfoRequest": false,
527                }),
528            );
529            let _ = shared.emit_event("initialized", json!({}));
530        }
531        "setBreakpoints" => {
532            let path = args["source"]["path"].as_str().unwrap_or("").to_string();
533            // Canonicalize so `check_line` lookup hits regardless of
534            // relative vs absolute path differences between IDE +
535            // executor.
536            let canon_path = std::fs::canonicalize(&path)
537                .map(|p| p.to_string_lossy().into_owned())
538                .unwrap_or_else(|_| path.clone());
539            let mut lines: Vec<u32> = Vec::new();
540            let mut verified = Vec::new();
541            if let Some(arr) = args["breakpoints"].as_array() {
542                for b in arr {
543                    if let Some(l) = b["line"].as_u64() {
544                        lines.push(l as u32);
545                        verified.push(json!({ "verified": true, "line": l }));
546                    }
547                }
548            }
549            tracing::info!(
550                target: "zshrs::dap::breakpoints",
551                path = %canon_path,
552                count = lines.len(),
553                lines = ?lines,
554                "registered",
555            );
556            if let Ok(mut bp) = bp_state.lock() {
557                if !canon_path.is_empty() {
558                    bp.line_breakpoints.insert(canon_path, lines);
559                }
560            }
561            let _ = shared.emit_response(req_seq, &cmd, true, json!({ "breakpoints": verified }));
562        }
563        "setExceptionBreakpoints" => {
564            let _ = shared.emit_response(req_seq, &cmd, true, json!({}));
565        }
566        "configurationDone" => {
567            shared.configuration_done.store(true, Ordering::SeqCst);
568            let _ = shared.emit_response(req_seq, &cmd, true, json!({}));
569        }
570        "launch" => {
571            let program_raw = args["program"].as_str().unwrap_or("").to_string();
572            // Canonicalize so it matches the canonicalized path the
573            // setBreakpoints handler stored.
574            let program = std::fs::canonicalize(&program_raw)
575                .map(|p| p.to_string_lossy().into_owned())
576                .unwrap_or(program_raw);
577            let cwd = args["cwd"].as_str().map(|s| s.to_string());
578            let lp_args: Vec<String> = args["args"]
579                .as_array()
580                .map(|a| {
581                    a.iter()
582                        .filter_map(|v| v.as_str().map(String::from))
583                        .collect()
584                })
585                .unwrap_or_default();
586            let stop_on_entry = args["stopOnEntry"].as_bool().unwrap_or(false);
587            let _ = shared.emit_response(req_seq, &cmd, true, json!({}));
588            let _ = launch_tx.send(LaunchParams {
589                program,
590                cwd,
591                args: lp_args,
592                stop_on_entry,
593            });
594        }
595        "threads" => {
596            let _ = shared.emit_response(
597                req_seq,
598                &cmd,
599                true,
600                json!({ "threads": [{ "id": 1, "name": "main" }] }),
601            );
602        }
603        "stackTrace" => {
604            // v1: synthesize a single frame from the launched program +
605            // current $LINENO. Multi-frame call-stack walk-back is
606            // future work (needs funcstack reflection).
607            let program = shared.program.lock().map(|g| g.clone()).unwrap_or_default();
608            let line = current_lineno();
609            let frames = vec![json!({
610                "id": 1,
611                "name": "main",
612                "source": { "path": program, "name": file_name(&program) },
613                "line": line,
614                "column": 1,
615            })];
616            let _ = shared.emit_response(
617                req_seq,
618                &cmd,
619                true,
620                json!({ "stackFrames": frames, "totalFrames": 1 }),
621            );
622        }
623        "scopes" => {
624            // Three separate scopes so IntelliJ renders them as
625            // collapsible groups in this fixed order — `Locals` (user
626            // vars) first, then `Specials` (zsh PM_SPECIAL params),
627            // then `Environment` (caps-name env vars). One big scope
628            // gets alpha-sorted client-side, burying the user's vars
629            // under 300 env entries. The IDE's sort toggle still works
630            // WITHIN each scope but the groups themselves are stable.
631            let _ = shared.emit_response(
632                req_seq,
633                &cmd,
634                true,
635                json!({
636                    "scopes": [
637                        { "name": "Locals",      "variablesReference": 1, "expensive": false, "presentationHint": "locals" },
638                        { "name": "Specials",    "variablesReference": 2, "expensive": false },
639                        { "name": "Environment", "variablesReference": 3, "expensive": false },
640                    ],
641                }),
642            );
643        }
644        "variables" => {
645            let r = args["variablesReference"].as_u64().unwrap_or(1);
646            let vars = match r {
647                1 => snapshot_user_vars(),
648                2 => snapshot_special_vars(),
649                3 => snapshot_env_vars(),
650                _ => snapshot_user_vars(),
651            };
652            let _ = shared.emit_response(req_seq, &cmd, true, json!({ "variables": vars }));
653        }
654        "evaluate" => {
655            let expr = args["expression"].as_str().unwrap_or("");
656            let (result, ty) = evaluate_expression(expr);
657            let _ = shared.emit_response(
658                req_seq,
659                &cmd,
660                true,
661                json!({
662                    "result": result,
663                    "type": ty,
664                    "variablesReference": 0,
665                }),
666            );
667        }
668        "continue" => {
669            let _ =
670                shared.emit_response(req_seq, &cmd, true, json!({ "allThreadsContinued": true }));
671            shared.resume_with(DebugAction::Continue);
672        }
673        "next" => {
674            let _ =
675                shared.emit_response(req_seq, &cmd, true, json!({ "allThreadsContinued": true }));
676            shared.resume_with(DebugAction::StepOver);
677        }
678        "stepIn" => {
679            let _ =
680                shared.emit_response(req_seq, &cmd, true, json!({ "allThreadsContinued": true }));
681            shared.resume_with(DebugAction::StepIn);
682        }
683        "stepOut" => {
684            let _ =
685                shared.emit_response(req_seq, &cmd, true, json!({ "allThreadsContinued": true }));
686            shared.resume_with(DebugAction::StepOut);
687        }
688        "pause" => {
689            let _ = shared.emit_response(req_seq, &cmd, true, json!({}));
690            shared.request_pause();
691        }
692        "disconnect" | "terminate" => {
693            let _ = shared.emit_response(req_seq, &cmd, true, json!({}));
694            shared.disconnected.store(true, Ordering::SeqCst);
695            shared.resume_with(DebugAction::Quit);
696        }
697        "source" => {
698            let _ = shared.emit_response(req_seq, &cmd, true, json!({}));
699        }
700        _ => {
701            tracing::debug!(target: "zshrs::dap", %cmd, "unsupported request");
702            let _ = shared.emit_response(req_seq, &cmd, false, json!({ "error": "unsupported" }));
703        }
704    }
705}
706
707/// Read the current `$LINENO` via paramtab — same path BUILTIN_SET_LINENO
708/// writes to. Default to 1 when unset.
709fn current_lineno() -> u32 {
710    crate::ported::params::paramtab()
711        .read()
712        .ok()
713        .and_then(|t| t.get("LINENO").map(|pm| pm.u_val as u32))
714        .unwrap_or(1)
715}
716
717/// Buckets all paramtab entries into (user, specials, env) by zsh
718/// `PM_SPECIAL` flag + caps-name + process-env presence. Returned
719/// tuples are sorted alpha within each bucket. Used by the three
720/// `snapshot_*_vars` helpers so each scope returns just its bucket.
721///
722/// Splitting into separate scopes (instead of one big list) means
723/// IntelliJ renders them as collapsible groups in fixed order, even
724/// when the user has "Sort Values Alphabetically" enabled — that
725/// toggle only sorts WITHIN a scope, not across scopes.
726fn snapshot_bucketed() -> (
727    Vec<(String, String)>,
728    Vec<(String, String)>,
729    Vec<(String, String)>,
730) {
731    let mut user: Vec<(String, String)> = Vec::new();
732    let mut specials: Vec<(String, String)> = Vec::new();
733    let mut env: Vec<(String, String)> = Vec::new();
734
735    if let Ok(tab) = crate::ported::params::paramtab().read() {
736        for (name, pm) in tab.iter() {
737            if matches!(
738                name.as_str(),
739                "_" | "PIPESTATUS" | "pipestatus" | "ZSH_ARGZERO"
740            ) {
741                continue;
742            }
743            let value = if pm.u_val != 0
744                && (pm.node.flags & crate::ported::zsh_h::PM_INTEGER as i32) != 0
745            {
746                pm.u_val.to_string()
747            } else if let Some(s) = pm.u_str.as_ref() {
748                s.clone()
749            } else {
750                String::new()
751            };
752            let is_special = (pm.node.flags & crate::ported::zsh_h::PM_SPECIAL as i32) != 0;
753            // Environment FIRST — most users think of PATH/HOME/USER
754            // as env vars even though zsh marks them PM_SPECIAL. The
755            // process-env presence is what makes them env, not the
756            // zsh flag.
757            let in_process_env = std::env::var(name).is_ok();
758            // Caps-only names not in env → zsh internal specials
759            // bucket (CPUTYPE, MACHTYPE, OSTYPE, HOST, etc).
760            let is_caps_only = !name.is_empty()
761                && name
762                    .chars()
763                    .all(|c| c.is_ascii_uppercase() || c == '_' || c.is_ascii_digit())
764                && name
765                    .chars()
766                    .next()
767                    .map(|c| c.is_ascii_uppercase())
768                    .unwrap_or(false);
769            if in_process_env {
770                env.push((name.clone(), value));
771            } else if is_special || is_caps_only {
772                specials.push((name.clone(), value));
773            } else {
774                user.push((name.clone(), value));
775            }
776        }
777    }
778    if user.is_empty() && specials.is_empty() && env.is_empty() {
779        for (k, v) in std::env::vars() {
780            env.push((k, v));
781        }
782    }
783    user.sort_by(|a, b| a.0.cmp(&b.0));
784    specials.sort_by(|a, b| a.0.cmp(&b.0));
785    env.sort_by(|a, b| a.0.cmp(&b.0));
786    (user, specials, env)
787}
788
789fn vars_to_json(vars: &[(String, String)]) -> Vec<Value> {
790    vars.iter()
791        .take(500)
792        .map(|(n, v)| {
793            json!({
794                "name": n,
795                "value": v,
796                "type": "scalar",
797                "variablesReference": 0,
798            })
799        })
800        .collect()
801}
802
803fn snapshot_user_vars() -> Vec<Value> {
804    let (user, _, _) = snapshot_bucketed();
805    vars_to_json(&user)
806}
807
808fn snapshot_special_vars() -> Vec<Value> {
809    let (_, specials, _) = snapshot_bucketed();
810    vars_to_json(&specials)
811}
812
813fn snapshot_env_vars() -> Vec<Value> {
814    let (_, _, env) = snapshot_bucketed();
815    vars_to_json(&env)
816}
817
818fn evaluate_expression(expr: &str) -> (String, String) {
819    // v1: run a fresh subshell to evaluate. Doesn't see the paused
820    // executor's local scope, but works for `$VAR` / `$(cmd)` / `$((expr))`.
821    let exe = std::env::current_exe().unwrap_or_else(|_| "zshrs".into());
822    let mut cmd = Command::new(exe);
823    cmd.arg("-c").arg(expr);
824    cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
825    match cmd.output() {
826        Ok(o) => {
827            if o.status.success() {
828                let s = String::from_utf8_lossy(&o.stdout).trim_end().to_string();
829                (s, "scalar".into())
830            } else {
831                let s = String::from_utf8_lossy(&o.stderr).trim_end().to_string();
832                (s, "error".into())
833            }
834        }
835        Err(e) => (format!("evaluate: {}", e), "error".into()),
836    }
837}
838// ── Framing ──────────────────────────────────────────────────────────────
839
840fn read_message<R: BufRead>(reader: &mut R) -> io::Result<Option<Value>> {
841    let mut content_length: Option<usize> = None;
842    loop {
843        let mut line = String::new();
844        let n = reader.read_line(&mut line)?;
845        if n == 0 {
846            return Ok(None);
847        }
848        if line == "\r\n" || line == "\n" {
849            break;
850        }
851        if let Some(rest) = line.strip_prefix("Content-Length:") {
852            content_length =
853                Some(rest.trim().parse().map_err(|_| {
854                    io::Error::new(io::ErrorKind::InvalidData, "bad Content-Length")
855                })?);
856        }
857    }
858    let len = content_length
859        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing Content-Length"))?;
860    let mut buf = vec![0u8; len];
861    reader.read_exact(&mut buf)?;
862    let v: Value =
863        serde_json::from_slice(&buf).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
864    Ok(Some(v))
865}
866
867fn write_message<W: Write>(mut writer: W, msg: &Value) -> io::Result<()> {
868    let body = serde_json::to_vec(msg)?;
869    write!(writer, "Content-Length: {}\r\n\r\n", body.len())?;
870    writer.write_all(&body)?;
871    writer.flush()
872}
873
874fn file_name(path: &str) -> String {
875    path.rsplit_once('/')
876        .map(|x| x.1)
877        .unwrap_or(path)
878        .to_string()
879}