Skip to main content

xei_core/
dap.rs

1//! Debug Adapter Protocol (DAP) client — launch, breakpoints, step, stack/vars.
2//!
3//! Talks DAP with Content-Length framing over stdio (same transport as LSP).
4//! Auto-picks a local adapter when available:
5//! - Python → `python -m debugpy.adapter` / `debugpy-adapter`
6//! - Go → `dlv dap`
7//! - Rust / C / C++ → `lldb-dap` / `codelldb` / `lldb-vscode`
8//! - Node → `js-debug-adapter` (if present)
9//!
10//! Sequence (matches VS Code): `initialize` → response → `launch` → adapter
11//! emits `initialized` → `setBreakpoints*` → `setExceptionBreakpoints` →
12//! `configurationDone` → launch response. Adapters that never emit
13//! `initialized` get the configuration after a 2s fallback in `poll()`.
14//!
15//! UI surface lives in the TUI (`Mode::Debug`); this module is headless-safe.
16
17use std::collections::HashMap;
18use std::io::{BufRead, BufReader, Read, Write};
19use std::net::TcpStream;
20use std::path::{Path, PathBuf};
21use std::process::{Child, Command, Stdio};
22use std::sync::mpsc::{self, Receiver, TryRecvError};
23use std::thread;
24use std::time::{Duration, Instant};
25
26use serde_json::{json, Value};
27
28/// Panel slide-up duration.
29pub const DAP_PANEL_ANIM_MS: u64 = 200;
30/// If the adapter never sends `initialized`, push configuration after this.
31const CONFIG_FALLBACK: Duration = Duration::from_secs(2);
32/// Grace period between terminate/disconnect and SIGKILL.
33const SHUTDOWN_GRACE: Duration = Duration::from_millis(1000);
34/// Cap on stack frames requested per stop.
35const STACK_LEVELS: u64 = 40;
36
37// ── Public types ───────────────────────────────────────────────────────────
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum DapState {
41    Idle,
42    Starting,
43    Running,
44    Stopped,
45    Ending,
46}
47
48impl DapState {
49    pub fn label(self) -> &'static str {
50        match self {
51            DapState::Idle => "idle",
52            DapState::Starting => "starting",
53            DapState::Running => "running",
54            DapState::Stopped => "stopped",
55            DapState::Ending => "ending",
56        }
57    }
58}
59
60#[derive(Debug, Clone)]
61pub struct Breakpoint {
62    /// 0-based line
63    pub line: usize,
64    pub verified: bool,
65    pub message: String,
66    /// Optional DAP condition expression
67    pub condition: Option<String>,
68    /// Optional logpoint message (adapter-dependent)
69    pub log_message: Option<String>,
70}
71
72#[derive(Debug, Clone)]
73pub struct StackFrameInfo {
74    pub id: i64,
75    pub name: String,
76    pub path: String,
77    /// 0-based
78    pub line: usize,
79    pub column: usize,
80}
81
82/// One row of the Variables tree (scopes are depth-0 roots).
83#[derive(Debug, Clone)]
84pub struct VarNode {
85    pub name: String,
86    pub value: String,
87    pub typ: String,
88    /// >0 = expandable (has children on the adapter side)
89    pub var_ref: i64,
90    pub depth: usize,
91    pub expanded: bool,
92    pub is_scope: bool,
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum DebugPane {
97    Stack,
98    Variables,
99    Breakpoints,
100    Console,
101}
102
103impl DebugPane {
104    pub fn label(self) -> &'static str {
105        match self {
106            DebugPane::Stack => "Stack",
107            DebugPane::Variables => "Vars",
108            DebugPane::Breakpoints => "BPs",
109            DebugPane::Console => "Console",
110        }
111    }
112
113    pub fn next(self) -> Self {
114        match self {
115            DebugPane::Stack => DebugPane::Variables,
116            DebugPane::Variables => DebugPane::Breakpoints,
117            DebugPane::Breakpoints => DebugPane::Console,
118            DebugPane::Console => DebugPane::Stack,
119        }
120    }
121
122    pub fn prev(self) -> Self {
123        match self {
124            DebugPane::Stack => DebugPane::Console,
125            DebugPane::Variables => DebugPane::Stack,
126            DebugPane::Breakpoints => DebugPane::Variables,
127            DebugPane::Console => DebugPane::Breakpoints,
128        }
129    }
130}
131
132#[derive(Debug, Clone)]
133enum PendingKind {
134    Initialize,
135    /// launch *or* attach response
136    Launch,
137    SetBreakpoints(String),
138    ExceptionBreakpoints,
139    ConfigDone,
140    StackTrace,
141    Scopes,
142    /// variablesReference the children belong to
143    Variables(i64),
144    Threads,
145    Continue,
146    Next,
147    StepIn,
148    StepOut,
149    Pause,
150    Terminate,
151    Disconnect,
152    Evaluate,
153}
154
155// ── Client ─────────────────────────────────────────────────────────────────
156
157pub struct DapClient {
158    /// Outbound DAP writer (adapter stdin or TCP stream).
159    writer: Option<Box<dyn Write + Send>>,
160    rx: Option<Receiver<Value>>,
161    child: Option<Child>,
162    next_id: u64,
163    pending: HashMap<u64, PendingKind>,
164
165    pub state: DapState,
166    pub adapter_name: String,
167    pub error: Option<String>,
168    /// Soft hint (adapter missing, etc.)
169    pub soft_error: Option<String>,
170
171    /// canonical path → breakpoints (0-based lines)
172    pub breakpoints: HashMap<String, Vec<Breakpoint>>,
173    pub stack: Vec<StackFrameInfo>,
174    /// Flattened Variables tree (scope roots + expanded children).
175    pub vars: Vec<VarNode>,
176    pub console: Vec<String>,
177    /// (thread id, name) from the last `threads` response / thread events.
178    pub threads: Vec<(i64, String)>,
179
180    pub selected_frame: usize,
181    pub selected_bp: usize,
182    pub pane: DebugPane,
183    pub focus_row: usize,
184
185    pub thread_id: Option<i64>,
186    pub stopped_reason: Option<String>,
187    /// Current stopped location (path, 0-based line)
188    pub current_path: Option<String>,
189    pub current_line: Option<usize>,
190
191    /// Panel visible in the UI layout (independent of focus / Mode::Debug).
192    pub panel_open: bool,
193    /// Set when stopped location changes — TUI should jump editor once.
194    pub location_dirty: bool,
195    /// Program + args for last launch (for restart)
196    pub last_program: Option<String>,
197    pub last_cwd: Option<String>,
198    pub last_lang: Option<String>,
199    pub last_args: Vec<String>,
200    /// Last attach target for restart (e.g. "pid:1234" / "port:5678")
201    pub last_attach: Option<String>,
202
203    // Sequencer
204    supports_config_done: bool,
205    supports_terminate: bool,
206    /// Filters chosen from the adapter's exceptionBreakpointFilters.
207    exception_filters: Vec<String>,
208    /// Set when the launch/attach request went out; drives the config fallback timer.
209    launch_sent_at: Option<Instant>,
210    /// setBreakpoints/exception/configurationDone already sent.
211    config_sent: bool,
212    /// Launch *or* attach request body prepared at session start
213    launch_body: Option<Value>,
214    /// When true, send `attach` instead of `launch` after initialize.
215    is_attach: bool,
216    /// Deadline after terminate/disconnect before the adapter is killed.
217    shutdown_deadline: Option<Instant>,
218    /// Queued relaunch once the graceful stop reaches Idle.
219    restart_pending: Option<(String, Option<PathBuf>, Option<String>, Vec<String>)>,
220    /// pause requested while the thread id was still unknown.
221    pause_requested: bool,
222    /// stopped event arrived without threadId; stack fetch waits on `threads`.
223    awaiting_stack_thread: bool,
224
225    /// variablesReference → children (valid until the next resume).
226    children_cache: HashMap<i64, Vec<VarNode>>,
227    /// Memoized fs::canonicalize results (gutter runs per frame).
228    canon_cache: HashMap<String, String>,
229
230    // Panel entrance animation (lazy first-frame clock).
231    opened_at: Option<Instant>,
232    anim_pending: bool,
233
234    /// Console REPL input line (evaluate request).
235    pub eval_input: String,
236    /// Async `cargo build` for Rust when the binary is missing.
237    build_rx: Option<Receiver<Result<(String, PathBuf, String, Vec<String>), String>>>,
238    /// Status line while building ("cargo build…").
239    pub build_message: Option<String>,
240
241    /// Outgoing requests captured for sequence tests.
242    #[cfg(test)]
243    pub(crate) sent: Vec<Value>,
244}
245
246impl Default for DapClient {
247    fn default() -> Self {
248        Self::new()
249    }
250}
251
252impl DapClient {
253    pub fn new() -> Self {
254        Self {
255            writer: None,
256            rx: None,
257            child: None,
258            next_id: 1,
259            pending: HashMap::new(),
260            state: DapState::Idle,
261            adapter_name: String::new(),
262            error: None,
263            soft_error: None,
264            breakpoints: HashMap::new(),
265            stack: Vec::new(),
266            vars: Vec::new(),
267            console: Vec::new(),
268            threads: Vec::new(),
269            selected_frame: 0,
270            selected_bp: 0,
271            pane: DebugPane::Stack,
272            focus_row: 0,
273            thread_id: None,
274            stopped_reason: None,
275            current_path: None,
276            current_line: None,
277            panel_open: false,
278            location_dirty: false,
279            last_program: None,
280            last_cwd: None,
281            last_lang: None,
282            last_args: Vec::new(),
283            last_attach: None,
284            supports_config_done: true,
285            supports_terminate: false,
286            exception_filters: Vec::new(),
287            launch_sent_at: None,
288            config_sent: false,
289            launch_body: None,
290            is_attach: false,
291            shutdown_deadline: None,
292            restart_pending: None,
293            pause_requested: false,
294            awaiting_stack_thread: false,
295            children_cache: HashMap::new(),
296            canon_cache: HashMap::new(),
297            opened_at: None,
298            anim_pending: false,
299            eval_input: String::new(),
300            build_rx: None,
301            build_message: None,
302            #[cfg(test)]
303            sent: Vec::new(),
304        }
305    }
306
307    pub fn is_session(&self) -> bool {
308        matches!(
309            self.state,
310            DapState::Starting | DapState::Running | DapState::Stopped
311        )
312    }
313
314    // ── Panel animation ────────────────────────────────────────────────
315
316    /// Arm the slide-up; the clock starts on the first rendered frame.
317    pub fn arm_panel_animation(&mut self) {
318        self.anim_pending = true;
319        self.opened_at = None;
320    }
321
322    pub fn anim_progress(&mut self) -> f32 {
323        if self.anim_pending {
324            self.anim_pending = false;
325            self.opened_at = Some(Instant::now());
326            return 0.0;
327        }
328        let Some(t0) = self.opened_at else {
329            return 1.0;
330        };
331        (t0.elapsed().as_millis() as f32 / DAP_PANEL_ANIM_MS as f32).min(1.0)
332    }
333
334    // ── Console ────────────────────────────────────────────────────────
335
336    pub fn log(&mut self, msg: impl Into<String>) {
337        let was_tail =
338            self.pane == DebugPane::Console && self.focus_row + 1 >= self.console.len();
339        self.console.push(msg.into());
340        if self.console.len() > 400 {
341            let drop_n = self.console.len() - 300;
342            self.console.drain(0..drop_n);
343            self.focus_row = self.focus_row.saturating_sub(drop_n);
344        }
345        if was_tail && self.pane == DebugPane::Console {
346            self.focus_row = self.console.len().saturating_sub(1);
347        }
348    }
349
350    // ── Breakpoints ────────────────────────────────────────────────────
351
352    /// Memoized fs::canonicalize (the gutter asks every frame).
353    fn canon(&mut self, path: &str) -> String {
354        if let Some(c) = self.canon_cache.get(path) {
355            return c.clone();
356        }
357        let c = std::fs::canonicalize(path)
358            .map(|p| p.display().to_string())
359            .unwrap_or_else(|_| path.to_string());
360        self.canon_cache.insert(path.to_string(), c.clone());
361        c
362    }
363
364    /// Toggle breakpoint at 0-based line for `path`. Returns new state (true = on).
365    pub fn toggle_breakpoint(&mut self, path: &str, line: usize) -> bool {
366        let path = self.canon(path);
367        let entry = self.breakpoints.entry(path.clone()).or_default();
368        if let Some(i) = entry.iter().position(|b| b.line == line) {
369            entry.remove(i);
370            if entry.is_empty() {
371                self.breakpoints.remove(&path);
372            }
373            if self.is_session() {
374                self.send_set_breakpoints(&path);
375            }
376            let _ = self.persist_breakpoints();
377            return false;
378        }
379        entry.push(Breakpoint {
380            line,
381            verified: false,
382            message: String::new(),
383            condition: None,
384            log_message: None,
385        });
386        entry.sort_by_key(|b| b.line);
387        if self.is_session() {
388            self.send_set_breakpoints(&path);
389        }
390        let _ = self.persist_breakpoints();
391        true
392    }
393
394    /// Set / clear a condition on an existing BP (0-based line). Creates BP if missing.
395    pub fn set_breakpoint_condition(
396        &mut self,
397        path: &str,
398        line: usize,
399        condition: Option<String>,
400    ) {
401        let path = self.canon(path);
402        let entry = self.breakpoints.entry(path.clone()).or_default();
403        if let Some(b) = entry.iter_mut().find(|b| b.line == line) {
404            b.condition = condition.filter(|s| !s.trim().is_empty());
405        } else {
406            entry.push(Breakpoint {
407                line,
408                verified: false,
409                message: String::new(),
410                condition: condition.filter(|s| !s.trim().is_empty()),
411                log_message: None,
412            });
413            entry.sort_by_key(|b| b.line);
414        }
415        if self.is_session() {
416            self.send_set_breakpoints(&path);
417        }
418        let _ = self.persist_breakpoints();
419    }
420
421    /// Set / clear a logpoint message.
422    pub fn set_breakpoint_log(
423        &mut self,
424        path: &str,
425        line: usize,
426        log_message: Option<String>,
427    ) {
428        let path = self.canon(path);
429        let entry = self.breakpoints.entry(path.clone()).or_default();
430        if let Some(b) = entry.iter_mut().find(|b| b.line == line) {
431            b.log_message = log_message.filter(|s| !s.trim().is_empty());
432        } else {
433            entry.push(Breakpoint {
434                line,
435                verified: false,
436                message: String::new(),
437                condition: None,
438                log_message: log_message.filter(|s| !s.trim().is_empty()),
439            });
440            entry.sort_by_key(|b| b.line);
441        }
442        if self.is_session() {
443            self.send_set_breakpoints(&path);
444        }
445        let _ = self.persist_breakpoints();
446    }
447
448    pub fn has_breakpoint(&mut self, path: &str, line: usize) -> bool {
449        let path = self.canon(path);
450        self.breakpoints
451            .get(&path)
452            .map(|v| v.iter().any(|b| b.line == line))
453            .unwrap_or(false)
454    }
455
456    pub fn clear_breakpoints(&mut self) {
457        self.breakpoints.clear();
458        let _ = self.persist_breakpoints();
459    }
460
461    fn breakpoints_path() -> PathBuf {
462        let home = std::env::var("HOME")
463            .or_else(|_| std::env::var("USERPROFILE"))
464            .unwrap_or_else(|_| ".".into());
465        PathBuf::from(home).join(".xei").join("breakpoints")
466    }
467
468    /// Persist BPs to `~/.xei/breakpoints` (`path|line[:cond][:log=msg]`).
469    pub fn persist_breakpoints(&self) -> Result<(), String> {
470        let path = Self::breakpoints_path();
471        if let Some(parent) = path.parent() {
472            let _ = std::fs::create_dir_all(parent);
473        }
474        let mut out = String::from("# xei breakpoints — path|line|condition|log\n");
475        let mut keys: Vec<_> = self.breakpoints.keys().cloned().collect();
476        keys.sort();
477        for k in keys {
478            if let Some(list) = self.breakpoints.get(&k) {
479                for b in list {
480                    let cond = b.condition.as_deref().unwrap_or("");
481                    let log = b.log_message.as_deref().unwrap_or("");
482                    out.push_str(&format!("{}|{}|{}|{}\n", k, b.line, cond, log));
483                }
484            }
485        }
486        std::fs::write(path, out).map_err(|e| e.to_string())
487    }
488
489    /// Load BPs from `~/.xei/breakpoints` (merge into current map).
490    pub fn load_persisted_breakpoints(&mut self) {
491        let Ok(text) = std::fs::read_to_string(Self::breakpoints_path()) else {
492            return;
493        };
494        for line in text.lines() {
495            let line = line.trim();
496            if line.is_empty() || line.starts_with('#') {
497                continue;
498            }
499            let parts: Vec<&str> = line.splitn(4, '|').collect();
500            if parts.len() < 2 {
501                continue;
502            }
503            let path = parts[0].to_string();
504            let Ok(ln) = parts[1].parse::<usize>() else {
505                continue;
506            };
507            let cond = parts
508                .get(2)
509                .map(|s| s.to_string())
510                .filter(|s| !s.is_empty());
511            let log = parts
512                .get(3)
513                .map(|s| s.to_string())
514                .filter(|s| !s.is_empty());
515            let entry = self.breakpoints.entry(path).or_default();
516            if !entry.iter().any(|b| b.line == ln) {
517                entry.push(Breakpoint {
518                    line: ln,
519                    verified: false,
520                    message: String::new(),
521                    condition: cond,
522                    log_message: log,
523                });
524                entry.sort_by_key(|b| b.line);
525            }
526        }
527    }
528
529    /// All BP lines for a path (0-based).
530    pub fn lines_for(&mut self, path: &str) -> Vec<usize> {
531        let path = self.canon(path);
532        self.breakpoints
533            .get(&path)
534            .map(|v| v.iter().map(|b| b.line).collect())
535            .unwrap_or_default()
536    }
537
538    /// Stopped line if the session is currently stopped in `path`.
539    pub fn current_line_for(&mut self, path: &str) -> Option<usize> {
540        let line = self.current_line?;
541        let cur = self.current_path.clone()?;
542        if self.canon(path) == self.canon(&cur) {
543            Some(line)
544        } else {
545            None
546        }
547    }
548
549    /// Best-effort line tracking for buffer edits.
550    ///
551    /// - **Insert** (`delta > 0`): `anchor` is the line *after which* content
552    ///   was inserted (e.g. newline at end of `anchor`). BPs on `anchor` stay;
553    ///   BPs with `line > anchor` shift down by `delta`.
554    /// - **Delete** (`delta < 0`): `anchor` is the first deleted line
555    ///   (inclusive). BPs in `[anchor, anchor+|delta|)` are removed; later
556    ///   lines shift up.
557    ///
558    /// Live-updates the adapter mid-session.
559    pub fn shift_breakpoints(&mut self, path: &str, anchor: usize, delta: isize) {
560        if delta == 0 {
561            return;
562        }
563        let path = self.canon(path);
564        let Some(list) = self.breakpoints.get_mut(&path) else {
565            return;
566        };
567        if delta > 0 {
568            let d = delta as usize;
569            for b in list.iter_mut() {
570                if b.line > anchor {
571                    b.line += d;
572                }
573            }
574        } else {
575            let d = (-delta) as usize;
576            // Inclusive start at `anchor`
577            list.retain_mut(|b| {
578                if b.line < anchor {
579                    return true;
580                }
581                if b.line < anchor + d {
582                    return false; // inside the deleted span
583                }
584                b.line -= d;
585                true
586            });
587        }
588        list.sort_by_key(|b| b.line);
589        list.dedup_by_key(|b| b.line);
590        if list.is_empty() {
591            self.breakpoints.remove(&path);
592        }
593        if self.is_session() {
594            self.send_set_breakpoints(&path);
595        }
596        let _ = self.persist_breakpoints();
597    }
598
599    /// Flattened BP list for UI: (path, line 0-based, verified)
600    pub fn flat_bps(&self) -> Vec<(String, usize, bool)> {
601        let mut out = Vec::new();
602        let mut keys: Vec<_> = self.breakpoints.keys().cloned().collect();
603        keys.sort();
604        for k in keys {
605            if let Some(list) = self.breakpoints.get(&k) {
606                for b in list {
607                    out.push((k.clone(), b.line, b.verified));
608                }
609            }
610        }
611        out
612    }
613
614    // ── Session lifecycle ──────────────────────────────────────────────
615
616    /// Start debugging `program` (or current file for script langs).
617    pub fn start(
618        &mut self,
619        program: &str,
620        cwd: Option<&Path>,
621        lang_hint: Option<&str>,
622        args: &[String],
623    ) -> Result<(), String> {
624        if self.is_session() {
625            return Err("Debug session already active — stop first (Shift+F5)".into());
626        }
627        self.finish_shutdown();
628        self.canon_cache.clear();
629
630        let program_path = PathBuf::from(program);
631        let abs_prog = std::fs::canonicalize(&program_path)
632            .unwrap_or_else(|_| program_path.clone());
633        let cwd = cwd
634            .map(Path::to_path_buf)
635            .or_else(|| abs_prog.parent().map(|p| p.to_path_buf()))
636            .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
637
638        let lang = lang_hint
639            .map(|s| s.to_string())
640            .unwrap_or_else(|| detect_lang(&abs_prog));
641
642        // Node uses TCP DAP (js-debug) — not stdio.
643        if lang == "node" {
644            return self.start_node(&abs_prog.display().to_string(), Some(&cwd), args);
645        }
646
647        let (adapter_cmd, adapter_args, launch) = pick_adapter(&lang, &abs_prog, &cwd, args)?;
648
649        // Rust: if binary is missing, kick off `cargo build` then relaunch.
650        if lang == "rust" {
651            if let Some(bin) = launch.get("program").and_then(|p| p.as_str()) {
652                if !Path::new(bin).is_file() {
653                    return self.begin_cargo_build(
654                        &cwd,
655                        abs_prog.display().to_string(),
656                        lang,
657                        args.to_vec(),
658                    );
659                }
660            }
661        }
662
663        let mut child = Command::new(&adapter_cmd)
664            .args(&adapter_args)
665            .current_dir(&cwd)
666            .stdin(Stdio::piped())
667            .stdout(Stdio::piped())
668            .stderr(Stdio::piped())
669            .spawn()
670            .map_err(|e| format!("Failed to start {adapter_cmd}: {e}"))?;
671
672        let stdin = child
673            .stdin
674            .take()
675            .ok_or_else(|| "adapter stdin unavailable".to_string())?;
676        let stdout = child
677            .stdout
678            .take()
679            .ok_or_else(|| "adapter stdout unavailable".to_string())?;
680        let stderr = child.stderr.take();
681
682        let (tx, rx) = mpsc::channel();
683        thread::spawn(move || read_loop(stdout, tx));
684        drain_stderr(stderr);
685
686        self.begin_session_common(
687            Box::new(stdin),
688            rx,
689            Some(child),
690            &adapter_cmd,
691            &lang,
692            launch,
693            false,
694            Some(abs_prog.display().to_string()),
695            Some(cwd.display().to_string()),
696            args.to_vec(),
697            None,
698        );
699
700        let id = self.alloc(PendingKind::Initialize);
701        let init = json!({
702            "seq": id,
703            "type": "request",
704            "command": "initialize",
705            "arguments": {
706                "clientID": "xei",
707                "clientName": "xei",
708                "adapterID": lang,
709                "pathFormat": "path",
710                "linesStartAt1": true,
711                "columnsStartAt1": true,
712                "supportsVariableType": true,
713                "supportsVariablePaging": false,
714                "supportsRunInTerminalRequest": false,
715                "locale": "en-us"
716            }
717        });
718        self.send_json(&init);
719        Ok(())
720    }
721
722    /// Shared session bookkeeping after a transport is ready.
723    #[allow(clippy::too_many_arguments)]
724    fn begin_session_common(
725        &mut self,
726        writer: Box<dyn Write + Send>,
727        rx: Receiver<Value>,
728        child: Option<Child>,
729        adapter_name: &str,
730        lang: &str,
731        body: Value,
732        is_attach: bool,
733        program: Option<String>,
734        cwd: Option<String>,
735        args: Vec<String>,
736        attach_tag: Option<String>,
737    ) {
738        self.writer = Some(writer);
739        self.rx = Some(rx);
740        self.child = child;
741        self.adapter_name = adapter_name.to_string();
742        self.state = DapState::Starting;
743        self.error = None;
744        self.soft_error = None;
745        self.config_sent = false;
746        self.launch_sent_at = None;
747        self.launch_body = Some(body);
748        self.is_attach = is_attach;
749        self.last_program = program;
750        self.last_cwd = cwd;
751        self.last_lang = Some(lang.to_string());
752        self.last_args = args;
753        self.last_attach = attach_tag;
754        self.panel_open = true;
755        self.build_rx = None;
756        self.build_message = None;
757        self.stack.clear();
758        self.vars.clear();
759        self.threads.clear();
760        self.children_cache.clear();
761        self.current_line = None;
762        self.current_path = None;
763        self.stopped_reason = None;
764        self.thread_id = None;
765        self.pause_requested = false;
766        self.awaiting_stack_thread = false;
767        let kind = if is_attach { "attach" } else { "launch" };
768        self.log(format!(
769            "▶ {kind} · {adapter_name} · {lang} · {}",
770            self.last_program.as_deref().unwrap_or("-")
771        ));
772    }
773
774    fn send_initialize(&mut self, adapter_id: &str) {
775        let id = self.alloc(PendingKind::Initialize);
776        self.send_json(&json!({
777            "seq": id,
778            "type": "request",
779            "command": "initialize",
780            "arguments": {
781                "clientID": "xei",
782                "clientName": "xei",
783                "adapterID": adapter_id,
784                "pathFormat": "path",
785                "linesStartAt1": true,
786                "columnsStartAt1": true,
787                "supportsVariableType": true,
788                "supportsVariablePaging": false,
789                "supportsRunInTerminalRequest": false,
790                "locale": "en-us"
791            }
792        }));
793    }
794
795    /// Attach to a running process by PID (lldb-dap / codelldb).
796    pub fn attach_pid(&mut self, pid: u32) -> Result<(), String> {
797        if self.is_session() {
798            return Err("Debug session already active — stop first (Shift+F5)".into());
799        }
800        self.finish_shutdown();
801        let adapter = ["lldb-dap", "codelldb", "lldb-vscode"]
802            .into_iter()
803            .find(|c| command_exists(c))
804            .ok_or_else(|| install_hint("rust"))?;
805        let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
806        let mut child = Command::new(adapter)
807            .current_dir(&cwd)
808            .stdin(Stdio::piped())
809            .stdout(Stdio::piped())
810            .stderr(Stdio::piped())
811            .spawn()
812            .map_err(|e| format!("Failed to start {adapter}: {e}"))?;
813        let stdin = child
814            .stdin
815            .take()
816            .ok_or_else(|| "adapter stdin unavailable".to_string())?;
817        let stdout = child
818            .stdout
819            .take()
820            .ok_or_else(|| "adapter stdout unavailable".to_string())?;
821        drain_stderr(child.stderr.take());
822        let (tx, rx) = mpsc::channel();
823        thread::spawn(move || read_loop(stdout, tx));
824        let body = json!({
825            "name": format!("Attach PID {pid}"),
826            "type": "lldb",
827            "request": "attach",
828            "pid": pid,
829            "stopOnEntry": false
830        });
831        self.begin_session_common(
832            Box::new(stdin),
833            rx,
834            Some(child),
835            adapter,
836            "native",
837            body,
838            true,
839            Some(format!("pid:{pid}")),
840            Some(cwd.display().to_string()),
841            Vec::new(),
842            Some(format!("pid:{pid}")),
843        );
844        self.send_initialize("lldb");
845        Ok(())
846    }
847
848    /// Attach to a debug adapter / runtime listening on `host:port`.
849    ///
850    /// - `python` → debugpy.adapter stdio + attach connect
851    /// - `node` → js-debug TCP server + attach
852    /// - `native` / default → lldb-dap attach via connect (when supported)
853    pub fn attach_port(
854        &mut self,
855        port: u16,
856        lang_hint: Option<&str>,
857        host: Option<&str>,
858    ) -> Result<(), String> {
859        if self.is_session() {
860            return Err("Debug session already active — stop first (Shift+F5)".into());
861        }
862        self.finish_shutdown();
863        let host = host.unwrap_or("127.0.0.1");
864        let lang = lang_hint.unwrap_or("python");
865        let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
866
867        match lang {
868            "node" | "javascript" | "typescript" => {
869                // Connect to an already-listening js-debug / inspector, or start our own server
870                // and attach to the user-provided debug port via DAP attach.
871                self.start_js_debug_tcp_session(
872                    json!({
873                        "name": format!("Attach Node :{port}"),
874                        "type": "pwa-node",
875                        "request": "attach",
876                        "address": host,
877                        "port": port,
878                        "localRoot": cwd.display().to_string(),
879                        "skipFiles": ["<node_internals>/**"]
880                    }),
881                    true,
882                    Some(format!("port:{port}")),
883                    Some(cwd.display().to_string()),
884                    Vec::new(),
885                    Some(format!("port:{port}")),
886                )
887            }
888            "python" | "debugpy" => {
889                let py = if command_exists("python3") {
890                    "python3"
891                } else if command_exists("python") {
892                    "python"
893                } else {
894                    return Err(install_hint("python"));
895                };
896                let mut child = Command::new(py)
897                    .args(["-m", "debugpy.adapter"])
898                    .current_dir(&cwd)
899                    .stdin(Stdio::piped())
900                    .stdout(Stdio::piped())
901                    .stderr(Stdio::piped())
902                    .spawn()
903                    .map_err(|e| format!("Failed to start debugpy.adapter: {e}"))?;
904                let stdin = child
905                    .stdin
906                    .take()
907                    .ok_or_else(|| "adapter stdin unavailable".to_string())?;
908                let stdout = child
909                    .stdout
910                    .take()
911                    .ok_or_else(|| "adapter stdout unavailable".to_string())?;
912                drain_stderr(child.stderr.take());
913                let (tx, rx) = mpsc::channel();
914                thread::spawn(move || read_loop(stdout, tx));
915                let body = json!({
916                    "name": format!("Python Attach :{port}"),
917                    "type": "python",
918                    "request": "attach",
919                    "connect": { "host": host, "port": port },
920                    "justMyCode": true
921                });
922                self.begin_session_common(
923                    Box::new(stdin),
924                    rx,
925                    Some(child),
926                    "debugpy",
927                    "python",
928                    body,
929                    true,
930                    Some(format!("port:{port}")),
931                    Some(cwd.display().to_string()),
932                    Vec::new(),
933                    Some(format!("port:{port}")),
934                );
935                self.send_initialize("python");
936                Ok(())
937            }
938            _ => {
939                // Generic: lldb attach by connecting to a debugserver is rare;
940                // try process-less TCP attach body for adapters that support it.
941                let adapter = ["lldb-dap", "codelldb"]
942                    .into_iter()
943                    .find(|c| command_exists(c))
944                    .ok_or_else(|| {
945                        String::from(
946                            "No attach adapter. Use `:DapAttach pid <n>` or python/node port attach",
947                        )
948                    })?;
949                let mut child = Command::new(adapter)
950                    .current_dir(&cwd)
951                    .stdin(Stdio::piped())
952                    .stdout(Stdio::piped())
953                    .stderr(Stdio::piped())
954                    .spawn()
955                    .map_err(|e| format!("Failed to start {adapter}: {e}"))?;
956                let stdin = child
957                    .stdin
958                    .take()
959                    .ok_or_else(|| "adapter stdin unavailable".to_string())?;
960                let stdout = child
961                    .stdout
962                    .take()
963                    .ok_or_else(|| "adapter stdout unavailable".to_string())?;
964                drain_stderr(child.stderr.take());
965                let (tx, rx) = mpsc::channel();
966                thread::spawn(move || read_loop(stdout, tx));
967                let body = json!({
968                    "name": format!("Attach :{port}"),
969                    "type": "lldb",
970                    "request": "attach",
971                    "attachCommands": [format!("process connect connect://{host}:{port}")]
972                });
973                self.begin_session_common(
974                    Box::new(stdin),
975                    rx,
976                    Some(child),
977                    adapter,
978                    lang,
979                    body,
980                    true,
981                    Some(format!("port:{port}")),
982                    Some(cwd.display().to_string()),
983                    Vec::new(),
984                    Some(format!("port:{port}")),
985                );
986                self.send_initialize(adapter);
987                Ok(())
988            }
989        }
990    }
991
992    /// Launch a Node/JS program via js-debug over TCP (stdio is unsupported).
993    pub fn start_node(
994        &mut self,
995        program: &str,
996        cwd: Option<&Path>,
997        args: &[String],
998    ) -> Result<(), String> {
999        if self.is_session() {
1000            return Err("Debug session already active — stop first (Shift+F5)".into());
1001        }
1002        self.finish_shutdown();
1003        let program_path = PathBuf::from(program);
1004        let abs_prog = std::fs::canonicalize(&program_path)
1005            .unwrap_or_else(|_| program_path.clone());
1006        let cwd = cwd
1007            .map(Path::to_path_buf)
1008            .or_else(|| abs_prog.parent().map(|p| p.to_path_buf()))
1009            .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
1010        let body = json!({
1011            "name": "Launch Node",
1012            "type": "pwa-node",
1013            "request": "launch",
1014            "program": abs_prog.display().to_string(),
1015            "args": args,
1016            "cwd": cwd.display().to_string(),
1017            "console": "internalConsole",
1018            "skipFiles": ["<node_internals>/**"]
1019        });
1020        self.start_js_debug_tcp_session(
1021            body,
1022            false,
1023            Some(abs_prog.display().to_string()),
1024            Some(cwd.display().to_string()),
1025            args.to_vec(),
1026            None,
1027        )
1028    }
1029
1030    /// Spawn `js-debug-adapter` as a TCP DAP server and connect.
1031    fn start_js_debug_tcp_session(
1032        &mut self,
1033        body: Value,
1034        is_attach: bool,
1035        program: Option<String>,
1036        cwd: Option<String>,
1037        args: Vec<String>,
1038        attach_tag: Option<String>,
1039    ) -> Result<(), String> {
1040        let port = free_localhost_port().ok_or_else(|| "No free TCP port for js-debug".to_string())?;
1041        let adapter_cmd = if command_exists("js-debug-adapter") {
1042            "js-debug-adapter".to_string()
1043        } else if command_exists("node") {
1044            // Fallback: try npx vscode-js-debug style — require js-debug-adapter on PATH
1045            return Err(
1046                "js-debug-adapter not found. Install VS Code js-debug adapter on PATH".into(),
1047            );
1048        } else {
1049            return Err(install_hint("node"));
1050        };
1051
1052        let workdir = cwd
1053            .as_ref()
1054            .map(PathBuf::from)
1055            .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
1056
1057        // Common flags: --server=PORT  or  just PORT
1058        let mut child = Command::new(&adapter_cmd)
1059            .args([format!("--server={port}")])
1060            .current_dir(&workdir)
1061            .stdin(Stdio::null())
1062            .stdout(Stdio::piped())
1063            .stderr(Stdio::piped())
1064            .spawn()
1065            .or_else(|_| {
1066                Command::new(&adapter_cmd)
1067                    .arg(port.to_string())
1068                    .current_dir(&workdir)
1069                    .stdin(Stdio::null())
1070                    .stdout(Stdio::piped())
1071                    .stderr(Stdio::piped())
1072                    .spawn()
1073            })
1074            .map_err(|e| format!("Failed to start {adapter_cmd}: {e}"))?;
1075
1076        drain_stderr(child.stderr.take());
1077        if let Some(out) = child.stdout.take() {
1078            // Don't block forever; just drain in background
1079            thread::spawn(move || {
1080                let mut r = BufReader::new(out);
1081                let mut line = String::new();
1082                while r.read_line(&mut line).unwrap_or(0) > 0 {
1083                    line.clear();
1084                }
1085            });
1086        }
1087
1088        // Wait for the DAP TCP server
1089        let stream = wait_for_tcp("127.0.0.1", port, Duration::from_secs(3)).map_err(|e| {
1090            let _ = child.kill();
1091            e
1092        })?;
1093        let reader = stream
1094            .try_clone()
1095            .map_err(|e| format!("tcp clone: {e}"))?;
1096        let (tx, rx) = mpsc::channel();
1097        thread::spawn(move || read_loop(reader, tx));
1098
1099        self.begin_session_common(
1100            Box::new(stream),
1101            rx,
1102            Some(child),
1103            "js-debug",
1104            "node",
1105            body,
1106            is_attach,
1107            program,
1108            cwd.or_else(|| Some(workdir.display().to_string())),
1109            args,
1110            attach_tag,
1111        );
1112        self.send_initialize("pwa-node");
1113        Ok(())
1114    }
1115
1116    pub fn continue_exec(&mut self) {
1117        let Some(tid) = self.thread_id else {
1118            self.log("continue: no thread");
1119            return;
1120        };
1121        if self.state != DapState::Stopped {
1122            self.log("continue: not stopped");
1123            return;
1124        }
1125        let id = self.alloc(PendingKind::Continue);
1126        self.send_json(&json!({
1127            "seq": id,
1128            "type": "request",
1129            "command": "continue",
1130            "arguments": { "threadId": tid }
1131        }));
1132        self.on_resumed();
1133        self.log("→ continue");
1134    }
1135
1136    pub fn step_over(&mut self) {
1137        self.step_cmd("next", PendingKind::Next);
1138    }
1139    pub fn step_into(&mut self) {
1140        self.step_cmd("stepIn", PendingKind::StepIn);
1141    }
1142    pub fn step_out(&mut self) {
1143        self.step_cmd("stepOut", PendingKind::StepOut);
1144    }
1145
1146    fn step_cmd(&mut self, command: &str, kind: PendingKind) {
1147        let Some(tid) = self.thread_id else {
1148            self.log(format!("{command}: no thread"));
1149            return;
1150        };
1151        if self.state != DapState::Stopped {
1152            self.log(format!("{command}: not stopped"));
1153            return;
1154        }
1155        let id = self.alloc(kind);
1156        self.send_json(&json!({
1157            "seq": id,
1158            "type": "request",
1159            "command": command,
1160            "arguments": { "threadId": tid }
1161        }));
1162        self.on_resumed();
1163        self.log(format!("→ {command}"));
1164    }
1165
1166    /// Suspend a running program (F6).
1167    pub fn pause(&mut self) {
1168        if self.state != DapState::Running {
1169            self.log("pause: not running");
1170            return;
1171        }
1172        if let Some(tid) = self.thread_id {
1173            let id = self.alloc(PendingKind::Pause);
1174            self.send_json(&json!({
1175                "seq": id,
1176                "type": "request",
1177                "command": "pause",
1178                "arguments": { "threadId": tid }
1179            }));
1180            self.log("→ pause");
1181        } else {
1182            // Thread id unknown while running — fetch, then pause on response.
1183            self.pause_requested = true;
1184            self.request_threads();
1185        }
1186    }
1187
1188    /// Variable references die on resume.
1189    fn on_resumed(&mut self) {
1190        self.state = DapState::Running;
1191        self.current_line = None;
1192        self.children_cache.clear();
1193    }
1194
1195    /// Graceful stop: terminate/disconnect, then SIGKILL after [`SHUTDOWN_GRACE`]
1196    /// (enforced in `poll`). Pressing stop twice force-kills.
1197    pub fn stop(&mut self) {
1198        if self.writer.is_none() {
1199            self.finish_shutdown();
1200            return;
1201        }
1202        if self.state == DapState::Ending {
1203            self.log("■ force kill");
1204            self.finish_shutdown();
1205            return;
1206        }
1207        self.state = DapState::Ending;
1208        if self.supports_terminate {
1209            let id = self.alloc(PendingKind::Terminate);
1210            self.send_json(&json!({
1211                "seq": id,
1212                "type": "request",
1213                "command": "terminate",
1214                "arguments": { "restart": false }
1215            }));
1216        } else {
1217            let id = self.alloc(PendingKind::Disconnect);
1218            self.send_json(&json!({
1219                "seq": id,
1220                "type": "request",
1221                "command": "disconnect",
1222                "arguments": { "restart": false, "terminateDebuggee": true }
1223            }));
1224        }
1225        self.shutdown_deadline = Some(Instant::now() + SHUTDOWN_GRACE);
1226        self.log("■ stopping…");
1227    }
1228
1229    /// Queue a relaunch; it fires from `poll()` once the graceful stop lands.
1230    pub fn restart(&mut self) -> Result<(), String> {
1231        let prog = self
1232            .last_program
1233            .clone()
1234            .ok_or_else(|| "No previous program".to_string())?;
1235        let cwd = self.last_cwd.clone().map(PathBuf::from);
1236        let lang = self.last_lang.clone();
1237        let args = self.last_args.clone();
1238        if self.is_session() || self.state == DapState::Ending {
1239            self.restart_pending = Some((prog, cwd, lang, args));
1240            self.stop();
1241            self.log("↻ restart queued");
1242            Ok(())
1243        } else {
1244            self.start(&prog, cwd.as_deref(), lang.as_deref(), &args)
1245        }
1246    }
1247
1248    fn finish_shutdown(&mut self) {
1249        self.writer = None;
1250        self.rx = None;
1251        if let Some(mut c) = self.child.take() {
1252            let _ = c.kill();
1253            let _ = c.wait();
1254        }
1255        self.pending.clear();
1256        self.config_sent = false;
1257        self.launch_sent_at = None;
1258        self.launch_body = None;
1259        self.shutdown_deadline = None;
1260        self.pause_requested = false;
1261        self.awaiting_stack_thread = false;
1262        self.thread_id = None;
1263        self.current_line = None;
1264        self.children_cache.clear();
1265        // Keep build_rx if a build is still running
1266        if self.build_rx.is_none() {
1267            self.build_message = None;
1268        }
1269        if self.state != DapState::Idle && self.build_rx.is_none() {
1270            self.state = DapState::Idle;
1271            self.log("session ended");
1272        }
1273    }
1274
1275    /// Spawn `cargo build` in the background; on success `poll` re-enters `start`.
1276    fn begin_cargo_build(
1277        &mut self,
1278        cwd: &Path,
1279        program: String,
1280        lang: String,
1281        args: Vec<String>,
1282    ) -> Result<(), String> {
1283        if !command_exists("cargo") {
1284            return Err(format!(
1285                "Binary missing and cargo not found — build the project first"
1286            ));
1287        }
1288        let (tx, rx) = mpsc::channel();
1289        let cwd_b = cwd.to_path_buf();
1290        let prog_for_resolve = program.clone();
1291        let lang_c = lang.clone();
1292        let args_c = args.clone();
1293        thread::spawn(move || {
1294            let out = Command::new("cargo")
1295                .args(["build"])
1296                .current_dir(&cwd_b)
1297                .stdout(Stdio::piped())
1298                .stderr(Stdio::piped())
1299                .output();
1300            match out {
1301                Ok(o) if o.status.success() => {
1302                    let bin = resolve_rust_bin(&cwd_b, Path::new(&prog_for_resolve))
1303                        .unwrap_or_else(|| {
1304                            resolve_rust_bin(&cwd_b, Path::new("src/main.rs"))
1305                                .unwrap_or(prog_for_resolve)
1306                        });
1307                    if Path::new(&bin).is_file() {
1308                        let _ = tx.send(Ok((bin, cwd_b, lang_c, args_c)));
1309                    } else {
1310                        let _ = tx.send(Err(format!(
1311                            "cargo build ok but binary not found: {bin}"
1312                        )));
1313                    }
1314                }
1315                Ok(o) => {
1316                    let err = String::from_utf8_lossy(&o.stderr);
1317                    let msg = err
1318                        .lines()
1319                        .rev()
1320                        .find(|l| !l.trim().is_empty())
1321                        .unwrap_or("cargo build failed")
1322                        .to_string();
1323                    let _ = tx.send(Err(msg));
1324                }
1325                Err(e) => {
1326                    let _ = tx.send(Err(format!("cargo spawn: {e}")));
1327                }
1328            }
1329        });
1330        self.build_rx = Some(rx);
1331        self.build_message = Some("cargo build…".into());
1332        self.panel_open = true;
1333        self.state = DapState::Starting;
1334        self.last_program = Some(program);
1335        self.last_cwd = Some(cwd.display().to_string());
1336        self.last_lang = Some(lang);
1337        self.last_args = args;
1338        self.log("⚙ cargo build… (will launch when done)");
1339        Ok(())
1340    }
1341
1342    // ── Requests ───────────────────────────────────────────────────────
1343
1344    fn alloc(&mut self, kind: PendingKind) -> u64 {
1345        let id = self.next_id;
1346        self.next_id = self.next_id.saturating_add(1);
1347        self.pending.insert(id, kind);
1348        id
1349    }
1350
1351    fn send_json(&mut self, v: &Value) {
1352        #[cfg(test)]
1353        self.sent.push(v.clone());
1354        let body = v.to_string();
1355        if let Some(ref mut writer) = self.writer {
1356            let header = format!("Content-Length: {}\r\n\r\n", body.len());
1357            let _ = writer.write_all(header.as_bytes());
1358            let _ = writer.write_all(body.as_bytes());
1359            let _ = writer.flush();
1360        }
1361    }
1362
1363    fn send_set_breakpoints(&mut self, path: &str) {
1364        let lines = self
1365            .breakpoints
1366            .get(path)
1367            .map(|v| {
1368                v.iter()
1369                    .map(|b| {
1370                        let mut o = json!({ "line": b.line + 1 });
1371                        if let Some(ref c) = b.condition {
1372                            o["condition"] = json!(c);
1373                        }
1374                        if let Some(ref m) = b.log_message {
1375                            o["logMessage"] = json!(m);
1376                        }
1377                        o
1378                    })
1379                    .collect::<Vec<_>>()
1380            })
1381            .unwrap_or_default();
1382        let id = self.alloc(PendingKind::SetBreakpoints(path.to_string()));
1383        self.send_json(&json!({
1384            "seq": id,
1385            "type": "request",
1386            "command": "setBreakpoints",
1387            "arguments": {
1388                "source": {
1389                    "path": path,
1390                    "name": Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or(path)
1391                },
1392                "breakpoints": lines,
1393                "sourceModified": false
1394            }
1395        }));
1396    }
1397
1398    /// Evaluate expression in the current stopped frame (REPL / watch).
1399    pub fn evaluate(&mut self, expression: &str) {
1400        let expr = expression.trim();
1401        if expr.is_empty() {
1402            return;
1403        }
1404        if self.state != DapState::Stopped {
1405            self.log("eval: not stopped");
1406            return;
1407        }
1408        let frame_id = self
1409            .stack
1410            .get(self.selected_frame)
1411            .map(|f| f.id)
1412            .unwrap_or(0);
1413        self.log(format!("> {expr}"));
1414        let id = self.alloc(PendingKind::Evaluate);
1415        self.send_json(&json!({
1416            "seq": id,
1417            "type": "request",
1418            "command": "evaluate",
1419            "arguments": {
1420                "expression": expr,
1421                "frameId": frame_id,
1422                "context": "repl"
1423            }
1424        }));
1425        self.eval_input.clear();
1426    }
1427
1428    /// setBreakpoints* → setExceptionBreakpoints → configurationDone.
1429    /// Responses may arrive later; ordering of the requests is what matters.
1430    fn send_configuration(&mut self) {
1431        if self.config_sent {
1432            return;
1433        }
1434        self.config_sent = true;
1435        let paths: Vec<String> = self.breakpoints.keys().cloned().collect();
1436        for p in paths {
1437            self.send_set_breakpoints(&p);
1438        }
1439        if !self.exception_filters.is_empty() {
1440            let filters = self.exception_filters.clone();
1441            let id = self.alloc(PendingKind::ExceptionBreakpoints);
1442            self.send_json(&json!({
1443                "seq": id,
1444                "type": "request",
1445                "command": "setExceptionBreakpoints",
1446                "arguments": { "filters": filters }
1447            }));
1448        }
1449        if self.supports_config_done {
1450            let id = self.alloc(PendingKind::ConfigDone);
1451            self.send_json(&json!({
1452                "seq": id,
1453                "type": "request",
1454                "command": "configurationDone"
1455            }));
1456        }
1457    }
1458
1459    fn request_threads(&mut self) {
1460        let id = self.alloc(PendingKind::Threads);
1461        self.send_json(&json!({
1462            "seq": id,
1463            "type": "request",
1464            "command": "threads"
1465        }));
1466    }
1467
1468    fn request_stack(&mut self) {
1469        let Some(tid) = self.thread_id else { return };
1470        let id = self.alloc(PendingKind::StackTrace);
1471        self.send_json(&json!({
1472            "seq": id,
1473            "type": "request",
1474            "command": "stackTrace",
1475            "arguments": {
1476                "threadId": tid,
1477                "startFrame": 0,
1478                "levels": STACK_LEVELS
1479            }
1480        }));
1481    }
1482
1483    fn request_scopes(&mut self, frame_id: i64) {
1484        let id = self.alloc(PendingKind::Scopes);
1485        self.send_json(&json!({
1486            "seq": id,
1487            "type": "request",
1488            "command": "scopes",
1489            "arguments": { "frameId": frame_id }
1490        }));
1491    }
1492
1493    fn request_variables(&mut self, variables_reference: i64) {
1494        if variables_reference <= 0 {
1495            return;
1496        }
1497        let id = self.alloc(PendingKind::Variables(variables_reference));
1498        self.send_json(&json!({
1499            "seq": id,
1500            "type": "request",
1501            "command": "variables",
1502            "arguments": { "variablesReference": variables_reference }
1503        }));
1504    }
1505
1506    // ── Panel navigation ───────────────────────────────────────────────
1507
1508    /// Select stack frame by index and load its scopes.
1509    pub fn select_frame(&mut self, idx: usize) {
1510        if idx >= self.stack.len() {
1511            return;
1512        }
1513        self.selected_frame = idx;
1514        self.focus_row = idx;
1515        let frame = &self.stack[idx];
1516        self.current_path = Some(frame.path.clone());
1517        self.current_line = Some(frame.line);
1518        let fid = frame.id;
1519        self.vars.clear();
1520        self.request_scopes(fid);
1521    }
1522
1523    /// Expand/collapse the Variables tree node at `idx`.
1524    pub fn toggle_var_at(&mut self, idx: usize) {
1525        let Some(node) = self.vars.get(idx) else {
1526            return;
1527        };
1528        if node.var_ref <= 0 {
1529            return;
1530        }
1531        if node.expanded {
1532            let depth = node.depth;
1533            self.vars[idx].expanded = false;
1534            let mut end = idx + 1;
1535            while end < self.vars.len() && self.vars[end].depth > depth {
1536                end += 1;
1537            }
1538            self.vars.drain(idx + 1..end);
1539        } else {
1540            if self.state != DapState::Stopped {
1541                // Refs die on resume — don't fetch stale children.
1542                return;
1543            }
1544            self.vars[idx].expanded = true;
1545            let vr = self.vars[idx].var_ref;
1546            if let Some(children) = self.children_cache.get(&vr).cloned() {
1547                self.insert_children(vr, children);
1548            } else {
1549                self.request_variables(vr);
1550            }
1551        }
1552    }
1553
1554    /// Splice `children` in under the (expanded) node holding `var_ref`.
1555    fn insert_children(&mut self, var_ref: i64, children: Vec<VarNode>) {
1556        let Some(pos) = self
1557            .vars
1558            .iter()
1559            .position(|n| n.var_ref == var_ref && n.expanded)
1560        else {
1561            return; // collapsed (or gone) while the request was in flight
1562        };
1563        let depth = self.vars[pos].depth + 1;
1564        let mut rows = children;
1565        for r in &mut rows {
1566            r.depth = depth;
1567            r.expanded = false;
1568        }
1569        // Replace any previous children (refresh case).
1570        let mut end = pos + 1;
1571        while end < self.vars.len() && self.vars[end].depth > self.vars[pos].depth {
1572            end += 1;
1573        }
1574        self.vars.splice(pos + 1..end, rows);
1575    }
1576
1577    /// Move panel focus; selection only — network requests stay on Enter.
1578    pub fn move_focus(&mut self, delta: isize) {
1579        let len = match self.pane {
1580            DebugPane::Stack => self.stack.len(),
1581            DebugPane::Variables => self.vars.len(),
1582            DebugPane::Breakpoints => self.flat_bps().len(),
1583            DebugPane::Console => self.console.len(),
1584        };
1585        if len == 0 {
1586            self.focus_row = 0;
1587            return;
1588        }
1589        let cur = self.focus_row as isize + delta;
1590        self.focus_row = cur.clamp(0, (len as isize) - 1) as usize;
1591        match self.pane {
1592            DebugPane::Stack => self.selected_frame = self.focus_row,
1593            DebugPane::Breakpoints => self.selected_bp = self.focus_row,
1594            DebugPane::Variables | DebugPane::Console => {}
1595        }
1596    }
1597
1598    /// Switch pane, placing focus sensibly (console starts at the tail).
1599    pub fn set_pane(&mut self, pane: DebugPane) {
1600        self.pane = pane;
1601        self.focus_row = match pane {
1602            DebugPane::Stack => self.selected_frame.min(self.stack.len().saturating_sub(1)),
1603            DebugPane::Console => self.console.len().saturating_sub(1),
1604            _ => 0,
1605        };
1606    }
1607
1608    // ── Poll & dispatch ────────────────────────────────────────────────
1609
1610    pub fn poll(&mut self) {
1611        // Async cargo build completion
1612        if let Some(rx) = self.build_rx.take() {
1613            match rx.try_recv() {
1614                Ok(Ok((bin, cwd, lang, args))) => {
1615                    self.build_message = None;
1616                    self.log(format!("✓ cargo build ok · launching {bin}"));
1617                    if let Err(e) = self.start(&bin, Some(&cwd), Some(&lang), &args) {
1618                        self.soft_error = Some(e.clone());
1619                        self.log(format!("✗ launch after build: {e}"));
1620                        self.state = DapState::Idle;
1621                    }
1622                }
1623                Ok(Err(e)) => {
1624                    self.build_message = None;
1625                    self.build_rx = None;
1626                    self.state = DapState::Idle;
1627                    self.soft_error = Some(e.clone());
1628                    self.log(format!("✗ cargo build: {e}"));
1629                }
1630                Err(TryRecvError::Empty) => {
1631                    self.build_rx = Some(rx);
1632                }
1633                Err(TryRecvError::Disconnected) => {
1634                    self.build_message = None;
1635                    self.state = DapState::Idle;
1636                }
1637            }
1638        }
1639        // Enforce the shutdown grace deadline.
1640        if let Some(deadline) = self.shutdown_deadline {
1641            if Instant::now() >= deadline {
1642                self.log("■ grace expired — killing adapter");
1643                self.finish_shutdown();
1644            }
1645        }
1646        // Config fallback for adapters that never emit `initialized`.
1647        if !self.config_sent
1648            && self.writer.is_some()
1649            && self
1650                .launch_sent_at
1651                .map(|t| t.elapsed() >= CONFIG_FALLBACK)
1652                .unwrap_or(false)
1653        {
1654            self.log("no initialized event — sending configuration anyway");
1655            self.send_configuration();
1656        }
1657        // Queued restart once the previous session fully lands.
1658        if self.state == DapState::Idle {
1659            if let Some((prog, cwd, lang, args)) = self.restart_pending.take() {
1660                if let Err(e) = self.start(&prog, cwd.as_deref(), lang.as_deref(), &args) {
1661                    self.log(format!("restart failed: {e}"));
1662                }
1663            }
1664        }
1665
1666        let mut batch = Vec::new();
1667        if let Some(ref rx) = self.rx {
1668            loop {
1669                match rx.try_recv() {
1670                    Ok(m) => batch.push(m),
1671                    Err(TryRecvError::Empty) => break,
1672                    Err(TryRecvError::Disconnected) => {
1673                        if self.state == DapState::Starting {
1674                            let hint = self
1675                                .last_lang
1676                                .as_deref()
1677                                .map(install_hint)
1678                                .unwrap_or_default();
1679                            self.error =
1680                                Some(format!("Debug adapter exited at startup. {hint}"));
1681                            self.log("adapter exited at startup");
1682                        } else if self.is_session() {
1683                            self.error = Some("Debug adapter disconnected".into());
1684                            self.log("adapter disconnected");
1685                        }
1686                        self.finish_shutdown();
1687                        break;
1688                    }
1689                }
1690            }
1691        }
1692        for msg in batch {
1693            self.handle_msg(msg);
1694        }
1695    }
1696
1697    fn handle_msg(&mut self, v: Value) {
1698        match v.get("type").and_then(|t| t.as_str()).unwrap_or("") {
1699            "event" => self.handle_event(&v),
1700            "response" => self.handle_response(&v),
1701            "request" => self.handle_reverse_request(&v),
1702            _ => {}
1703        }
1704    }
1705
1706    fn handle_event(&mut self, v: &Value) {
1707        let event = v.get("event").and_then(|e| e.as_str()).unwrap_or("");
1708        let body = v.get("body").cloned().unwrap_or(json!({}));
1709        match event {
1710            "initialized" => {
1711                // Adapter is ready for breakpoints + configurationDone.
1712                self.send_configuration();
1713            }
1714            "stopped" => {
1715                self.state = DapState::Stopped;
1716                let reason = body
1717                    .get("reason")
1718                    .and_then(|r| r.as_str())
1719                    .unwrap_or("stopped")
1720                    .to_string();
1721                self.stopped_reason = Some(reason.clone());
1722                self.children_cache.clear();
1723                if let Some(tid) = body.get("threadId").and_then(|t| t.as_i64()) {
1724                    self.thread_id = Some(tid);
1725                    self.log(format!("● stopped ({reason})"));
1726                    self.request_stack();
1727                } else {
1728                    // Legal when allThreadsStopped — find a thread first.
1729                    self.log(format!("● stopped ({reason}) — resolving thread"));
1730                    self.awaiting_stack_thread = true;
1731                    self.request_threads();
1732                }
1733            }
1734            "continued" => {
1735                self.on_resumed();
1736            }
1737            "thread" => {
1738                let tid = body.get("threadId").and_then(|t| t.as_i64()).unwrap_or(0);
1739                match body.get("reason").and_then(|r| r.as_str()).unwrap_or("") {
1740                    "started" => {
1741                        if !self.threads.iter().any(|(id, _)| *id == tid) {
1742                            self.threads.push((tid, format!("thread {tid}")));
1743                        }
1744                    }
1745                    "exited" => {
1746                        self.threads.retain(|(id, _)| *id != tid);
1747                        if self.thread_id == Some(tid) {
1748                            self.thread_id = None;
1749                        }
1750                    }
1751                    _ => {}
1752                }
1753            }
1754            "breakpoint" => {
1755                self.apply_breakpoint_event(&body);
1756            }
1757            "terminated" => {
1758                self.log("■ terminated");
1759                self.finish_shutdown();
1760            }
1761            "exited" => {
1762                let code = body
1763                    .get("exitCode")
1764                    .and_then(|c| c.as_i64())
1765                    .unwrap_or_default();
1766                // Exit info only — `terminated` ends the session.
1767                self.log(format!("program exited with code {code}"));
1768            }
1769            "output" => {
1770                let cat = body
1771                    .get("category")
1772                    .and_then(|c| c.as_str())
1773                    .unwrap_or("console");
1774                let out = body
1775                    .get("output")
1776                    .and_then(|o| o.as_str())
1777                    .unwrap_or("")
1778                    .trim_end()
1779                    .to_string();
1780                if !out.is_empty() {
1781                    for line in out.lines() {
1782                        self.log(format!("[{cat}] {line}"));
1783                    }
1784                }
1785            }
1786            _ => {}
1787        }
1788    }
1789
1790    /// Adapter re-verified / moved a breakpoint after launch.
1791    fn apply_breakpoint_event(&mut self, body: &Value) {
1792        let Some(bp) = body.get("breakpoint") else {
1793            return;
1794        };
1795        let line = bp
1796            .get("line")
1797            .and_then(|l| l.as_u64())
1798            .map(|l| l.saturating_sub(1) as usize);
1799        let verified = bp
1800            .get("verified")
1801            .and_then(|x| x.as_bool())
1802            .unwrap_or(false);
1803        let message = bp
1804            .get("message")
1805            .and_then(|m| m.as_str())
1806            .unwrap_or("")
1807            .to_string();
1808        let src_path = bp
1809            .get("source")
1810            .and_then(|s| s.get("path"))
1811            .and_then(|p| p.as_str())
1812            .map(|s| s.to_string());
1813        let Some(line) = line else { return };
1814        let canon_src = src_path.map(|p| self.canon(&p));
1815        for (path, list) in self.breakpoints.iter_mut() {
1816            if canon_src.as_ref().map(|s| s == path).unwrap_or(true) {
1817                if let Some(b) = list.iter_mut().find(|b| b.line == line) {
1818                    b.verified = verified;
1819                    b.message = message.clone();
1820                }
1821            }
1822        }
1823    }
1824
1825    fn handle_response(&mut self, v: &Value) {
1826        let id = v.get("request_seq").and_then(|x| x.as_u64());
1827        let success = v.get("success").and_then(|s| s.as_bool()).unwrap_or(false);
1828        let command = v.get("command").and_then(|c| c.as_str()).unwrap_or("");
1829        let body = v.get("body").cloned().unwrap_or(json!({}));
1830        let kind = id.and_then(|i| self.pending.remove(&i));
1831
1832        if !success {
1833            let msg = v
1834                .get("message")
1835                .and_then(|m| m.as_str())
1836                .unwrap_or("request failed");
1837            self.log(format!("✗ {command}: {msg}"));
1838            match kind {
1839                Some(PendingKind::Initialize | PendingKind::Launch) => {
1840                    self.error = Some(msg.to_string());
1841                    self.finish_shutdown();
1842                }
1843                Some(PendingKind::Terminate) => {
1844                    // Fall back to disconnect within the same grace window.
1845                    let id = self.alloc(PendingKind::Disconnect);
1846                    self.send_json(&json!({
1847                        "seq": id,
1848                        "type": "request",
1849                        "command": "disconnect",
1850                        "arguments": { "restart": false, "terminateDebuggee": true }
1851                    }));
1852                }
1853                _ => {}
1854            }
1855            return;
1856        }
1857
1858        match kind {
1859            Some(PendingKind::Initialize) => {
1860                self.supports_config_done = body
1861                    .get("supportsConfigurationDoneRequest")
1862                    .and_then(|x| x.as_bool())
1863                    .unwrap_or(true);
1864                self.supports_terminate = body
1865                    .get("supportsTerminateRequest")
1866                    .and_then(|x| x.as_bool())
1867                    .unwrap_or(false);
1868                self.exception_filters = pick_exception_filters(&body);
1869                // Spec order: launch/attach goes out now; the adapter answers it only
1870                // after configurationDone (which follows its `initialized`).
1871                if let Some(args) = self.launch_body.take() {
1872                    let id = self.alloc(PendingKind::Launch);
1873                    let cmd = if self.is_attach { "attach" } else { "launch" };
1874                    self.send_json(&json!({
1875                        "seq": id,
1876                        "type": "request",
1877                        "command": cmd,
1878                        "arguments": args
1879                    }));
1880                    self.launch_sent_at = Some(Instant::now());
1881                    self.log(format!("initialize ok → {cmd}"));
1882                }
1883            }
1884            Some(PendingKind::Launch) => {
1885                let kind = if self.is_attach { "attach" } else { "launch" };
1886                self.log(format!("{kind} ok"));
1887                if self.state == DapState::Starting {
1888                    self.state = DapState::Running;
1889                }
1890            }
1891            Some(PendingKind::SetBreakpoints(path)) => {
1892                // Response array is 1:1 with the (line-sorted) request array.
1893                if let Some(arr) = body.get("breakpoints").and_then(|b| b.as_array()) {
1894                    if let Some(list) = self.breakpoints.get_mut(&path) {
1895                        for (b, resp) in list.iter_mut().zip(arr) {
1896                            b.verified = resp
1897                                .get("verified")
1898                                .and_then(|x| x.as_bool())
1899                                .unwrap_or(false);
1900                            b.message = resp
1901                                .get("message")
1902                                .and_then(|m| m.as_str())
1903                                .unwrap_or("")
1904                                .to_string();
1905                            // Adapter may slide the BP to the nearest valid line.
1906                            if let Some(l) = resp.get("line").and_then(|l| l.as_u64()) {
1907                                b.line = l.saturating_sub(1) as usize;
1908                            }
1909                        }
1910                        list.sort_by_key(|b| b.line);
1911                        list.dedup_by_key(|b| b.line);
1912                    }
1913                }
1914            }
1915            Some(PendingKind::Threads) => {
1916                self.threads = body
1917                    .get("threads")
1918                    .and_then(|t| t.as_array())
1919                    .map(|arr| {
1920                        arr.iter()
1921                            .map(|t| {
1922                                (
1923                                    t.get("id").and_then(|i| i.as_i64()).unwrap_or(0),
1924                                    t.get("name")
1925                                        .and_then(|n| n.as_str())
1926                                        .unwrap_or("thread")
1927                                        .to_string(),
1928                                )
1929                            })
1930                            .collect()
1931                    })
1932                    .unwrap_or_default();
1933                if self.thread_id.is_none() {
1934                    self.thread_id = self.threads.first().map(|(id, _)| *id);
1935                }
1936                if self.awaiting_stack_thread {
1937                    self.awaiting_stack_thread = false;
1938                    self.request_stack();
1939                }
1940                if self.pause_requested {
1941                    self.pause_requested = false;
1942                    if self.state == DapState::Running {
1943                        self.pause();
1944                    }
1945                }
1946            }
1947            Some(PendingKind::StackTrace) => {
1948                self.stack.clear();
1949                if let Some(arr) = body.get("stackFrames").and_then(|s| s.as_array()) {
1950                    for f in arr {
1951                        self.stack.push(StackFrameInfo {
1952                            id: f.get("id").and_then(|x| x.as_i64()).unwrap_or(0),
1953                            name: f
1954                                .get("name")
1955                                .and_then(|x| x.as_str())
1956                                .unwrap_or("??")
1957                                .to_string(),
1958                            path: f
1959                                .get("source")
1960                                .and_then(|s| s.get("path"))
1961                                .and_then(|p| p.as_str())
1962                                .unwrap_or("")
1963                                .to_string(),
1964                            line: f
1965                                .get("line")
1966                                .and_then(|l| l.as_u64())
1967                                .unwrap_or(1)
1968                                .saturating_sub(1) as usize,
1969                            column: f
1970                                .get("column")
1971                                .and_then(|c| c.as_u64())
1972                                .unwrap_or(1)
1973                                .saturating_sub(1) as usize,
1974                        });
1975                    }
1976                }
1977                if let Some(top) = self.stack.first() {
1978                    self.current_path = Some(top.path.clone());
1979                    self.current_line = Some(top.line);
1980                    self.selected_frame = 0;
1981                    if self.pane == DebugPane::Stack {
1982                        self.focus_row = 0;
1983                    }
1984                    self.location_dirty = true;
1985                    let fid = top.id;
1986                    self.vars.clear();
1987                    self.request_scopes(fid);
1988                }
1989            }
1990            Some(PendingKind::Scopes) => {
1991                self.vars.clear();
1992                if let Some(arr) = body.get("scopes").and_then(|s| s.as_array()) {
1993                    for s in arr {
1994                        self.vars.push(VarNode {
1995                            name: s
1996                                .get("name")
1997                                .and_then(|n| n.as_str())
1998                                .unwrap_or("scope")
1999                                .to_string(),
2000                            value: String::new(),
2001                            typ: String::new(),
2002                            var_ref: s
2003                                .get("variablesReference")
2004                                .and_then(|r| r.as_i64())
2005                                .unwrap_or(0),
2006                            depth: 0,
2007                            expanded: false,
2008                            is_scope: true,
2009                        });
2010                    }
2011                }
2012                // Auto-expand the first (usually "Locals") scope.
2013                if let Some(first) = self.vars.first_mut() {
2014                    if first.var_ref > 0 {
2015                        first.expanded = true;
2016                        let vr = first.var_ref;
2017                        self.request_variables(vr);
2018                    }
2019                }
2020                if self.pane == DebugPane::Variables {
2021                    self.focus_row = 0;
2022                }
2023            }
2024            Some(PendingKind::Variables(var_ref)) => {
2025                let children: Vec<VarNode> = body
2026                    .get("variables")
2027                    .and_then(|s| s.as_array())
2028                    .map(|arr| {
2029                        arr.iter()
2030                            .map(|var| VarNode {
2031                                name: var
2032                                    .get("name")
2033                                    .and_then(|n| n.as_str())
2034                                    .unwrap_or("?")
2035                                    .to_string(),
2036                                value: var
2037                                    .get("value")
2038                                    .and_then(|n| n.as_str())
2039                                    .unwrap_or("")
2040                                    .to_string(),
2041                                typ: var
2042                                    .get("type")
2043                                    .and_then(|n| n.as_str())
2044                                    .unwrap_or("")
2045                                    .to_string(),
2046                                var_ref: var
2047                                    .get("variablesReference")
2048                                    .and_then(|r| r.as_i64())
2049                                    .unwrap_or(0),
2050                                depth: 0,
2051                                expanded: false,
2052                                is_scope: false,
2053                            })
2054                            .collect()
2055                    })
2056                    .unwrap_or_default();
2057                self.children_cache.insert(var_ref, children.clone());
2058                self.insert_children(var_ref, children);
2059            }
2060            Some(
2061                PendingKind::Continue
2062                | PendingKind::Next
2063                | PendingKind::StepIn
2064                | PendingKind::StepOut
2065                | PendingKind::Pause,
2066            ) => {
2067                // stopped event will follow
2068            }
2069            Some(PendingKind::Terminate | PendingKind::Disconnect) => {
2070                // terminated event (or the grace deadline) completes shutdown
2071            }
2072            Some(PendingKind::Evaluate) => {
2073                let result = body
2074                    .get("result")
2075                    .and_then(|r| r.as_str())
2076                    .unwrap_or("(no result)");
2077                let typ = body
2078                    .get("type")
2079                    .and_then(|t| t.as_str())
2080                    .unwrap_or("");
2081                if typ.is_empty() {
2082                    self.log(format!("= {result}"));
2083                } else {
2084                    self.log(format!("= {result}  ({typ})"));
2085                }
2086            }
2087            Some(PendingKind::ExceptionBreakpoints | PendingKind::ConfigDone) | None => {}
2088        }
2089    }
2090
2091    fn handle_reverse_request(&mut self, v: &Value) {
2092        // runInTerminal etc. — reject gracefully
2093        let command = v.get("command").and_then(|c| c.as_str()).unwrap_or("");
2094        let seq = v.get("seq").and_then(|s| s.as_u64()).unwrap_or(0);
2095        self.log(format!("← reverse request {command} (unsupported)"));
2096        let id = self.next_id;
2097        self.next_id += 1;
2098        self.send_json(&json!({
2099            "seq": id,
2100            "type": "response",
2101            "request_seq": seq,
2102            "success": false,
2103            "command": command,
2104            "message": "not supported by xei"
2105        }));
2106    }
2107
2108    // ── Test scaffolding ───────────────────────────────────────────────
2109
2110    /// Pretend an adapter is attached (no process); requests land in `sent`.
2111    #[cfg(test)]
2112    fn test_session(&mut self, launch_body: Value) {
2113        self.state = DapState::Starting;
2114        self.launch_body = Some(launch_body);
2115        self.adapter_name = "mock".into();
2116        // stdin stays None — send_json records to `sent` in tests.
2117    }
2118
2119    #[cfg(test)]
2120    fn sent_commands(&self) -> Vec<String> {
2121        self.sent
2122            .iter()
2123            .filter_map(|v| v.get("command").and_then(|c| c.as_str()))
2124            .map(|s| s.to_string())
2125            .collect()
2126    }
2127}
2128
2129// ── Transport ──────────────────────────────────────────────────────────────
2130
2131fn read_loop<R: Read>(stdout: R, tx: mpsc::Sender<Value>) {
2132    let mut reader = BufReader::new(stdout);
2133    loop {
2134        let mut content_length: Option<usize> = None;
2135        loop {
2136            let mut line = String::new();
2137            match reader.read_line(&mut line) {
2138                Ok(0) => return,
2139                Ok(_) => {}
2140                Err(_) => return,
2141            }
2142            let t = line.trim_end();
2143            if t.is_empty() {
2144                break;
2145            }
2146            if let Some(rest) = t.strip_prefix("Content-Length:") {
2147                content_length = rest.trim().parse().ok();
2148            }
2149        }
2150        let Some(len) = content_length else {
2151            continue;
2152        };
2153        let mut buf = vec![0u8; len];
2154        if reader.read_exact(&mut buf).is_err() {
2155            return;
2156        }
2157        // Parse once here; the client works on Values.
2158        let Ok(v) = serde_json::from_slice::<Value>(&buf) else {
2159            continue;
2160        };
2161        if tx.send(v).is_err() {
2162            return;
2163        }
2164    }
2165}
2166
2167// ── Adapter selection ──────────────────────────────────────────────────────
2168
2169fn detect_lang(path: &Path) -> String {
2170    match path
2171        .extension()
2172        .and_then(|e| e.to_str())
2173        .unwrap_or("")
2174        .to_ascii_lowercase()
2175        .as_str()
2176    {
2177        "py" | "pyw" => "python".into(),
2178        "rs" => "rust".into(),
2179        "go" => "go".into(),
2180        "c" | "h" | "cc" | "cpp" | "cxx" | "hpp" => "cpp".into(),
2181        "js" | "mjs" | "cjs" | "ts" | "tsx" => "node".into(),
2182        "rb" => "ruby".into(),
2183        _ => "unknown".into(),
2184    }
2185}
2186
2187/// Default exception filters: the adapter's `default: true` ones, else
2188/// "uncaught" when offered.
2189fn pick_exception_filters(caps: &Value) -> Vec<String> {
2190    let Some(arr) = caps
2191        .get("exceptionBreakpointFilters")
2192        .and_then(|f| f.as_array())
2193    else {
2194        return Vec::new();
2195    };
2196    let defaults: Vec<String> = arr
2197        .iter()
2198        .filter(|f| f.get("default").and_then(|d| d.as_bool()).unwrap_or(false))
2199        .filter_map(|f| f.get("filter").and_then(|s| s.as_str()))
2200        .map(|s| s.to_string())
2201        .collect();
2202    if !defaults.is_empty() {
2203        return defaults;
2204    }
2205    arr.iter()
2206        .filter_map(|f| f.get("filter").and_then(|s| s.as_str()))
2207        .filter(|s| *s == "uncaught")
2208        .map(|s| s.to_string())
2209        .collect()
2210}
2211
2212fn pick_adapter(
2213    lang: &str,
2214    program: &Path,
2215    cwd: &Path,
2216    args: &[String],
2217) -> Result<(String, Vec<String>, Value), String> {
2218    let prog_s = program.display().to_string();
2219    let cwd_s = cwd.display().to_string();
2220    match lang {
2221        "python" => {
2222            let py = if command_exists("python3") {
2223                "python3"
2224            } else if command_exists("python") {
2225                "python"
2226            } else {
2227                return Err(install_hint(lang));
2228            };
2229            Ok((
2230                py.into(),
2231                vec!["-m".into(), "debugpy.adapter".into()],
2232                json!({
2233                    "name": "Python: current file",
2234                    "type": "python",
2235                    "request": "launch",
2236                    "program": prog_s,
2237                    "args": args,
2238                    "cwd": cwd_s,
2239                    "console": "internalConsole",
2240                    "justMyCode": true,
2241                    "stopOnEntry": false
2242                }),
2243            ))
2244        }
2245        "go" => {
2246            if !command_exists("dlv") {
2247                return Err(install_hint(lang));
2248            }
2249            Ok((
2250                "dlv".into(),
2251                vec!["dap".into()],
2252                json!({
2253                    "name": "Launch Go",
2254                    "type": "go",
2255                    "request": "launch",
2256                    "mode": "debug",
2257                    "program": prog_s,
2258                    "args": args,
2259                    "cwd": cwd_s
2260                }),
2261            ))
2262        }
2263        "rust" | "cpp" | "c" => {
2264            let adapter = ["lldb-dap", "codelldb", "lldb-vscode"]
2265                .into_iter()
2266                .find(|c| command_exists(c))
2267                .ok_or_else(|| install_hint(lang))?;
2268            // For rust source files, try cargo target/debug/<name>
2269            let program_bin = if lang == "rust"
2270                && program.extension().and_then(|e| e.to_str()) == Some("rs")
2271            {
2272                resolve_rust_bin(cwd, program).unwrap_or_else(|| prog_s.clone())
2273            } else {
2274                prog_s.clone()
2275            };
2276            // Missing binary is handled in `start()` via async cargo build.
2277            Ok((
2278                adapter.into(),
2279                vec![],
2280                json!({
2281                    "name": "Launch",
2282                    "type": "lldb",
2283                    "request": "launch",
2284                    "program": program_bin,
2285                    "args": args,
2286                    "cwd": cwd_s,
2287                    "stopOnEntry": false
2288                }),
2289            ))
2290        }
2291        "node" => {
2292            // Handled by start() → start_node (TCP). Keep a clear error if reached.
2293            Err("Node debugging uses TCP transport — call start_node".into())
2294        }
2295        _ => {
2296            // Generic: if path is executable, try lldb-dap
2297            let adapter = ["lldb-dap", "codelldb"]
2298                .into_iter()
2299                .find(|c| command_exists(c))
2300                .ok_or_else(|| install_hint(lang))?;
2301            if !program.is_file() {
2302                return Err(format!("Not an executable file: {prog_s}"));
2303            }
2304            Ok((
2305                adapter.into(),
2306                vec![],
2307                json!({
2308                    "name": "Launch",
2309                    "type": "lldb",
2310                    "request": "launch",
2311                    "program": prog_s,
2312                    "args": args,
2313                    "cwd": cwd_s
2314                }),
2315            ))
2316        }
2317    }
2318}
2319
2320fn resolve_rust_bin(cwd: &Path, src: &Path) -> Option<String> {
2321    // Prefer package name from Cargo.toml
2322    let mut dir = cwd.to_path_buf();
2323    for _ in 0..8 {
2324        let cargo = dir.join("Cargo.toml");
2325        if cargo.is_file() {
2326            if let Ok(text) = std::fs::read_to_string(&cargo) {
2327                if let Some(name) = parse_cargo_name(&text) {
2328                    return Some(dir.join("target/debug").join(&name).display().to_string());
2329                }
2330            }
2331            break;
2332        }
2333        if !dir.pop() {
2334            break;
2335        }
2336    }
2337    let stem = src.file_stem()?.to_str()?;
2338    Some(cwd.join("target/debug").join(stem).display().to_string())
2339}
2340
2341fn parse_cargo_name(toml: &str) -> Option<String> {
2342    let mut in_package = false;
2343    for line in toml.lines() {
2344        let t = line.trim();
2345        if t.starts_with('[') {
2346            in_package = t == "[package]";
2347            continue;
2348        }
2349        if in_package {
2350            if let Some(rest) = t.strip_prefix("name") {
2351                let rest = rest.trim().trim_start_matches('=').trim();
2352                let name = rest.trim_matches('"').trim_matches('\'').to_string();
2353                if !name.is_empty() {
2354                    return Some(name);
2355                }
2356            }
2357        }
2358    }
2359    None
2360}
2361
2362fn install_hint(lang: &str) -> String {
2363    match lang {
2364        "python" => "No Python DAP adapter. Install: pip install debugpy".into(),
2365        "go" => "No Go DAP adapter. Install: go install github.com/go-delve/delve/cmd/dlv@latest".into(),
2366        "rust" | "cpp" | "c" => {
2367            "No native DAP adapter. Install lldb-dap (LLVM) or CodeLLDB".into()
2368        }
2369        "node" => "No Node DAP adapter (js-debug-adapter) found".into(),
2370        _ => format!(
2371            "No DAP adapter for `{lang}`. Install debugpy / dlv / lldb-dap for your language"
2372        ),
2373    }
2374}
2375
2376fn command_exists(cmd: &str) -> bool {
2377    if cmd.contains('/') {
2378        return Path::new(cmd).is_file();
2379    }
2380    let Ok(path) = std::env::var("PATH") else {
2381        return false;
2382    };
2383    for dir in std::env::split_paths(&path) {
2384        let p = dir.join(cmd);
2385        if p.is_file() {
2386            return true;
2387        }
2388        // Windows
2389        let p_exe = dir.join(format!("{cmd}.exe"));
2390        if p_exe.is_file() {
2391            return true;
2392        }
2393    }
2394    false
2395}
2396
2397fn drain_stderr(stderr: Option<std::process::ChildStderr>) {
2398    if let Some(err) = stderr {
2399        thread::spawn(move || {
2400            let mut r = BufReader::new(err);
2401            let mut line = String::new();
2402            while r.read_line(&mut line).unwrap_or(0) > 0 {
2403                line.clear();
2404            }
2405        });
2406    }
2407}
2408
2409fn free_localhost_port() -> Option<u16> {
2410    let listener = std::net::TcpListener::bind("127.0.0.1:0").ok()?;
2411    listener.local_addr().ok().map(|a| a.port())
2412}
2413
2414fn wait_for_tcp(host: &str, port: u16, timeout: Duration) -> Result<TcpStream, String> {
2415    let start = Instant::now();
2416    let mut last_err = String::from("connect failed");
2417    while start.elapsed() < timeout {
2418        match TcpStream::connect((host, port)) {
2419            Ok(s) => {
2420                let _ = s.set_nodelay(true);
2421                return Ok(s);
2422            }
2423            Err(e) => {
2424                last_err = e.to_string();
2425                thread::sleep(Duration::from_millis(40));
2426            }
2427        }
2428    }
2429    Err(format!("TCP {host}:{port} not ready: {last_err}"))
2430}
2431
2432// ── launch.json subset ─────────────────────────────────────────────────────
2433
2434/// Minimal VS Code-compatible launch configuration.
2435#[derive(Debug, Clone)]
2436pub struct LaunchConfig {
2437    pub name: String,
2438    pub request: String,
2439    pub program: String,
2440    pub args: Vec<String>,
2441    pub cwd: Option<String>,
2442    pub env: Vec<(String, String)>,
2443    /// Original type field (python / lldb / go / …)
2444    pub adapter_type: String,
2445    /// Attach: process id when present
2446    pub pid: Option<u32>,
2447    /// Attach: TCP port when present
2448    pub port: Option<u16>,
2449    /// Attach host (default 127.0.0.1)
2450    pub host: Option<String>,
2451}
2452
2453/// Walk up from `hint` looking for `.vscode/launch.json` and parse configurations.
2454pub fn load_launch_configs(hint: Option<&Path>) -> Vec<LaunchConfig> {
2455    let start = hint
2456        .map(Path::to_path_buf)
2457        .or_else(|| std::env::current_dir().ok())
2458        .unwrap_or_else(|| PathBuf::from("."));
2459    let mut dir = if start.is_file() {
2460        start.parent().unwrap_or(Path::new(".")).to_path_buf()
2461    } else {
2462        start
2463    };
2464    for _ in 0..12 {
2465        let candidate = dir.join(".vscode").join("launch.json");
2466        if candidate.is_file() {
2467            if let Ok(text) = std::fs::read_to_string(&candidate) {
2468                // Strip // comments (VS Code allows them)
2469                let cleaned = strip_jsonc_comments(&text);
2470                if let Ok(v) = serde_json::from_str::<Value>(&cleaned) {
2471                    return parse_launch_configs(&v, &dir);
2472                }
2473            }
2474        }
2475        if !dir.pop() {
2476            break;
2477        }
2478    }
2479    Vec::new()
2480}
2481
2482fn strip_jsonc_comments(text: &str) -> String {
2483    let mut out = String::with_capacity(text.len());
2484    let mut chars = text.chars().peekable();
2485    let mut in_str = false;
2486    let mut escape = false;
2487    while let Some(c) = chars.next() {
2488        if in_str {
2489            out.push(c);
2490            if escape {
2491                escape = false;
2492            } else if c == '\\' {
2493                escape = true;
2494            } else if c == '"' {
2495                in_str = false;
2496            }
2497            continue;
2498        }
2499        if c == '"' {
2500            in_str = true;
2501            out.push(c);
2502            continue;
2503        }
2504        if c == '/' && chars.peek() == Some(&'/') {
2505            // line comment
2506            while let Some(n) = chars.next() {
2507                if n == '\n' {
2508                    out.push('\n');
2509                    break;
2510                }
2511            }
2512            continue;
2513        }
2514        if c == '/' && chars.peek() == Some(&'*') {
2515            chars.next();
2516            while let Some(n) = chars.next() {
2517                if n == '*' && chars.peek() == Some(&'/') {
2518                    chars.next();
2519                    break;
2520                }
2521            }
2522            continue;
2523        }
2524        out.push(c);
2525    }
2526    out
2527}
2528
2529fn parse_launch_configs(v: &Value, workspace: &Path) -> Vec<LaunchConfig> {
2530    let Some(arr) = v.get("configurations").and_then(|c| c.as_array()) else {
2531        return Vec::new();
2532    };
2533    let mut out = Vec::new();
2534    for c in arr {
2535        let request = c
2536            .get("request")
2537            .and_then(|r| r.as_str())
2538            .unwrap_or("launch")
2539            .to_string();
2540        if request != "launch" && request != "attach" {
2541            continue;
2542        }
2543        let name = c
2544            .get("name")
2545            .and_then(|n| n.as_str())
2546            .unwrap_or("unnamed")
2547            .to_string();
2548        let program = c
2549            .get("program")
2550            .and_then(|p| p.as_str())
2551            .unwrap_or("")
2552            .replace("${workspaceFolder}", &workspace.display().to_string())
2553            .replace("${file}", "");
2554        let args = c
2555            .get("args")
2556            .and_then(|a| a.as_array())
2557            .map(|a| {
2558                a.iter()
2559                    .filter_map(|x| x.as_str().map(|s| s.to_string()))
2560                    .collect()
2561            })
2562            .unwrap_or_default();
2563        let cwd = c
2564            .get("cwd")
2565            .and_then(|p| p.as_str())
2566            .map(|s| {
2567                s.replace("${workspaceFolder}", &workspace.display().to_string())
2568            });
2569        let mut env = Vec::new();
2570        if let Some(obj) = c.get("env").and_then(|e| e.as_object()) {
2571            for (k, v) in obj {
2572                if let Some(s) = v.as_str() {
2573                    env.push((k.clone(), s.to_string()));
2574                }
2575            }
2576        }
2577        let adapter_type = c
2578            .get("type")
2579            .and_then(|t| t.as_str())
2580            .unwrap_or("")
2581            .to_string();
2582        let pid = c
2583            .get("processId")
2584            .or_else(|| c.get("pid"))
2585            .and_then(|p| p.as_u64())
2586            .map(|p| p as u32);
2587        let port = c
2588            .get("port")
2589            .and_then(|p| p.as_u64())
2590            .or_else(|| {
2591                c.get("connect")
2592                    .and_then(|o| o.get("port"))
2593                    .and_then(|p| p.as_u64())
2594            })
2595            .map(|p| p as u16);
2596        let host = c
2597            .get("connect")
2598            .and_then(|o| o.get("host"))
2599            .and_then(|h| h.as_str())
2600            .or_else(|| c.get("address").and_then(|a| a.as_str()))
2601            .map(|s| s.to_string());
2602        out.push(LaunchConfig {
2603            name,
2604            request,
2605            program,
2606            args,
2607            cwd,
2608            env,
2609            adapter_type,
2610            pid,
2611            port,
2612            host,
2613        });
2614    }
2615    out
2616}
2617
2618// ── Tests ──────────────────────────────────────────────────────────────────
2619
2620#[cfg(test)]
2621mod tests {
2622    use super::*;
2623
2624    fn response(seq: u64, command: &str, body: Value) -> Value {
2625        json!({
2626            "type": "response",
2627            "request_seq": seq,
2628            "success": true,
2629            "command": command,
2630            "body": body
2631        })
2632    }
2633
2634    fn event(name: &str, body: Value) -> Value {
2635        json!({ "type": "event", "event": name, "body": body })
2636    }
2637
2638    /// seq of the last sent request matching `command`.
2639    fn seq_of(d: &DapClient, command: &str) -> u64 {
2640        d.sent
2641            .iter()
2642            .rev()
2643            .find(|v| v.get("command").and_then(|c| c.as_str()) == Some(command))
2644            .and_then(|v| v.get("seq").and_then(|s| s.as_u64()))
2645            .expect("request was sent")
2646    }
2647
2648    #[test]
2649    fn toggle_breakpoint_roundtrip() {
2650        let mut d = DapClient::new();
2651        assert!(d.toggle_breakpoint("/tmp/foo.py", 10));
2652        assert!(d.has_breakpoint("/tmp/foo.py", 10));
2653        assert!(!d.toggle_breakpoint("/tmp/foo.py", 10));
2654        assert!(!d.has_breakpoint("/tmp/foo.py", 10));
2655    }
2656
2657    #[test]
2658    fn condition_and_log_on_breakpoint() {
2659        let mut d = DapClient::new();
2660        d.set_breakpoint_condition("/tmp/a.py", 5, Some("x > 0".into()));
2661        d.set_breakpoint_log("/tmp/a.py", 5, Some("hit".into()));
2662        let path = d.canon("/tmp/a.py");
2663        let b = d.breakpoints.get(&path).unwrap().iter().find(|b| b.line == 5).unwrap();
2664        assert_eq!(b.condition.as_deref(), Some("x > 0"));
2665        assert_eq!(b.log_message.as_deref(), Some("hit"));
2666    }
2667
2668    #[test]
2669    fn parse_launch_json_minimal() {
2670        let j = r#"{
2671            // comment
2672            "configurations": [
2673                {
2674                    "name": "Run",
2675                    "type": "python",
2676                    "request": "launch",
2677                    "program": "${workspaceFolder}/main.py",
2678                    "args": ["a", "b"]
2679                },
2680                {
2681                    "name": "AttachPy",
2682                    "type": "python",
2683                    "request": "attach",
2684                    "connect": { "host": "127.0.0.1", "port": 5678 }
2685                }
2686            ]
2687        }"#;
2688        let cleaned = strip_jsonc_comments(j);
2689        let v: Value = serde_json::from_str(&cleaned).unwrap();
2690        let cfgs = parse_launch_configs(&v, Path::new("/proj"));
2691        assert_eq!(cfgs.len(), 2);
2692        assert_eq!(cfgs[0].name, "Run");
2693        assert_eq!(cfgs[0].program, "/proj/main.py");
2694        assert_eq!(cfgs[0].args, vec!["a", "b"]);
2695        assert_eq!(cfgs[1].request, "attach");
2696        assert_eq!(cfgs[1].port, Some(5678));
2697        assert_eq!(cfgs[1].host.as_deref(), Some("127.0.0.1"));
2698    }
2699
2700    #[test]
2701    fn free_port_and_wait_helpers() {
2702        let port = free_localhost_port().expect("port");
2703        // Nothing listening — wait should fail quickly
2704        let err = wait_for_tcp("127.0.0.1", port, Duration::from_millis(80));
2705        assert!(err.is_err());
2706    }
2707
2708    #[test]
2709    fn cargo_name_parse() {
2710        let t = "[package]\nname = \"xei-core\"\nversion = \"1\"\n";
2711        assert_eq!(parse_cargo_name(t).as_deref(), Some("xei-core"));
2712    }
2713
2714    #[test]
2715    fn detect_langs() {
2716        assert_eq!(detect_lang(Path::new("a.py")), "python");
2717        assert_eq!(detect_lang(Path::new("a.rs")), "rust");
2718        assert_eq!(detect_lang(Path::new("main.go")), "go");
2719    }
2720
2721    #[test]
2722    fn state_label() {
2723        assert_eq!(DapState::Stopped.label(), "stopped");
2724    }
2725
2726    #[test]
2727    fn sequencer_launch_after_initialize_config_after_initialized_event() {
2728        let mut d = DapClient::new();
2729        d.toggle_breakpoint("/tmp/x.py", 3);
2730        d.sent.clear();
2731        d.test_session(json!({"program": "/tmp/x.py"}));
2732
2733        // initialize response → launch must go out (and nothing config-ish yet)
2734        d.handle_msg(response(1, "initialize", json!({
2735            "supportsConfigurationDoneRequest": true,
2736            "supportsTerminateRequest": true,
2737            "exceptionBreakpointFilters": [
2738                {"filter": "raised", "label": "Raised", "default": false},
2739                {"filter": "uncaught", "label": "Uncaught", "default": true}
2740            ]
2741        })));
2742        // pending id for initialize isn't registered in test_session; drive via
2743        // the real alloc path instead: simulate full start bookkeeping.
2744        // (init response with unknown request_seq is a no-op — assert that.)
2745        assert!(d.sent_commands().is_empty());
2746
2747        // Register initialize as pending and retry.
2748        let init_id = d.alloc(PendingKind::Initialize);
2749        d.handle_msg(response(init_id, "initialize", json!({
2750            "supportsConfigurationDoneRequest": true,
2751            "supportsTerminateRequest": true,
2752            "exceptionBreakpointFilters": [
2753                {"filter": "uncaught", "label": "Uncaught", "default": true}
2754            ]
2755        })));
2756        assert_eq!(d.sent_commands(), vec!["launch"]);
2757        assert!(d.launch_sent_at.is_some());
2758        assert!(!d.config_sent);
2759
2760        // initialized event → setBreakpoints, setExceptionBreakpoints, configurationDone
2761        d.handle_msg(event("initialized", json!({})));
2762        let cmds = d.sent_commands();
2763        assert_eq!(
2764            cmds,
2765            vec![
2766                "launch",
2767                "setBreakpoints",
2768                "setExceptionBreakpoints",
2769                "configurationDone"
2770            ]
2771        );
2772        assert!(d.config_sent);
2773
2774        // duplicate initialized must not resend configuration
2775        d.handle_msg(event("initialized", json!({})));
2776        assert_eq!(d.sent_commands().len(), 4);
2777
2778        // launch response → Running
2779        let launch_seq = seq_of(&d, "launch");
2780        d.handle_msg(response(launch_seq, "launch", json!({})));
2781        assert_eq!(d.state, DapState::Running);
2782    }
2783
2784    #[test]
2785    fn stopped_without_thread_id_resolves_via_threads() {
2786        let mut d = DapClient::new();
2787        d.test_session(json!({}));
2788        d.state = DapState::Running;
2789        d.sent.clear();
2790
2791        d.handle_msg(event("stopped", json!({ "reason": "pause" })));
2792        assert_eq!(d.state, DapState::Stopped);
2793        assert_eq!(d.sent_commands(), vec!["threads"]);
2794
2795        let tseq = seq_of(&d, "threads");
2796        d.handle_msg(response(
2797            tseq,
2798            "threads",
2799            json!({ "threads": [{"id": 7, "name": "main"}] }),
2800        ));
2801        assert_eq!(d.thread_id, Some(7));
2802        assert_eq!(d.threads, vec![(7, "main".to_string())]);
2803        assert_eq!(d.sent_commands(), vec!["threads", "stackTrace"]);
2804    }
2805
2806    #[test]
2807    fn stack_scopes_variables_build_tree() {
2808        let mut d = DapClient::new();
2809        d.test_session(json!({}));
2810        d.state = DapState::Stopped;
2811        d.thread_id = Some(1);
2812        d.sent.clear();
2813
2814        d.request_stack();
2815        let sseq = seq_of(&d, "stackTrace");
2816        d.handle_msg(response(sseq, "stackTrace", json!({
2817            "stackFrames": [
2818                {"id": 100, "name": "main", "line": 12, "column": 1,
2819                 "source": {"path": "/tmp/x.py"}}
2820            ]
2821        })));
2822        assert_eq!(d.stack.len(), 1);
2823        assert_eq!(d.current_line, Some(11));
2824        assert!(d.location_dirty);
2825
2826        let scseq = seq_of(&d, "scopes");
2827        d.handle_msg(response(scseq, "scopes", json!({
2828            "scopes": [
2829                {"name": "Locals", "variablesReference": 200, "expensive": false},
2830                {"name": "Globals", "variablesReference": 300, "expensive": true}
2831            ]
2832        })));
2833        // Scope roots present, first auto-expanding.
2834        assert_eq!(d.vars.len(), 2);
2835        assert!(d.vars[0].is_scope && d.vars[0].expanded);
2836
2837        let vseq = seq_of(&d, "variables");
2838        d.handle_msg(response(vseq, "variables", json!({
2839            "variables": [
2840                {"name": "x", "value": "1", "type": "int", "variablesReference": 0},
2841                {"name": "items", "value": "[…]", "type": "list", "variablesReference": 400}
2842            ]
2843        })));
2844        assert_eq!(d.vars.len(), 4);
2845        assert_eq!(d.vars[1].name, "x");
2846        assert_eq!(d.vars[1].depth, 1);
2847        assert_eq!(d.vars[2].var_ref, 400);
2848
2849        // Collapse the scope removes its children.
2850        d.toggle_var_at(0);
2851        assert_eq!(d.vars.len(), 2);
2852        // Re-expand hits the cache without a new request.
2853        let n_before = d.sent.len();
2854        d.toggle_var_at(0);
2855        assert_eq!(d.vars.len(), 4);
2856        assert_eq!(d.sent.len(), n_before);
2857    }
2858
2859    #[test]
2860    fn graceful_stop_waits_for_terminated() {
2861        let mut d = DapClient::new();
2862        d.test_session(json!({}));
2863        d.state = DapState::Running;
2864        d.supports_terminate = true;
2865        // Pretend transport exists so stop() doesn't shortcut to Idle.
2866        // (stdin is None in tests; emulate by giving it a deadline manually.)
2867        d.sent.clear();
2868        d.state = DapState::Running;
2869        d.shutdown_deadline = None;
2870        // stop() with stdin None finishes immediately — assert Idle path…
2871        d.stop();
2872        assert_eq!(d.state, DapState::Idle);
2873        // …and the terminated-event path also lands Idle.
2874        d.state = DapState::Ending;
2875        d.handle_msg(event("terminated", json!({})));
2876        assert_eq!(d.state, DapState::Idle);
2877    }
2878
2879    #[test]
2880    fn breakpoint_event_updates_verified() {
2881        let mut d = DapClient::new();
2882        d.toggle_breakpoint("/tmp/x.py", 5);
2883        assert!(!d.flat_bps()[0].2);
2884        d.handle_msg(event("breakpoint", json!({
2885            "reason": "changed",
2886            "breakpoint": { "line": 6, "verified": true }
2887        })));
2888        assert!(d.flat_bps()[0].2);
2889    }
2890
2891    #[test]
2892    fn set_breakpoints_response_slides_lines() {
2893        let mut d = DapClient::new();
2894        d.toggle_breakpoint("/tmp/x.py", 4); // 0-based 4 → sent as line 5
2895        let path = d.breakpoints.keys().next().unwrap().clone();
2896        let id = d.alloc(PendingKind::SetBreakpoints(path.clone()));
2897        d.handle_msg(response(id, "setBreakpoints", json!({
2898            "breakpoints": [ {"verified": true, "line": 7} ]
2899        })));
2900        let list = &d.breakpoints[&path];
2901        assert_eq!(list[0].line, 6); // adapter moved it to line 7 (1-based)
2902        assert!(list[0].verified);
2903    }
2904
2905    #[test]
2906    fn shift_breakpoints_tracks_edits() {
2907        let mut d = DapClient::new();
2908        for l in [2usize, 5, 9] {
2909            d.toggle_breakpoint("/tmp/x.py", l);
2910        }
2911        // 2 lines inserted after line 3 → 5,9 shift; 2 stays.
2912        d.shift_breakpoints("/tmp/x.py", 3, 2);
2913        assert_eq!(d.lines_for("/tmp/x.py"), vec![2, 7, 11]);
2914        // 3 lines deleted after line 5 → BP at 7 falls inside span and dies, 11 → 8.
2915        d.shift_breakpoints("/tmp/x.py", 5, -3);
2916        assert_eq!(d.lines_for("/tmp/x.py"), vec![2, 8]);
2917    }
2918
2919    #[test]
2920    fn config_fallback_fires_without_initialized_event() {
2921        let mut d = DapClient::new();
2922        d.test_session(json!({}));
2923        let init_id = d.alloc(PendingKind::Initialize);
2924        d.handle_msg(response(init_id, "initialize", json!({})));
2925        assert!(!d.config_sent);
2926        // Rewind the launch clock past the fallback and poll.
2927        d.launch_sent_at = Some(Instant::now() - CONFIG_FALLBACK - Duration::from_millis(1));
2928        // poll() requires stdin.is_some() for the fallback — emulate the
2929        // condition by calling send_configuration directly through poll's gate:
2930        d.config_sent = false;
2931        d.send_configuration();
2932        assert!(d.config_sent);
2933    }
2934
2935    #[test]
2936    fn console_follows_tail_when_focused_there() {
2937        let mut d = DapClient::new();
2938        d.set_pane(DebugPane::Console);
2939        d.log("one");
2940        d.log("two");
2941        assert_eq!(d.focus_row, 1);
2942        // Scroll up — new logs must not yank focus back to the tail.
2943        d.move_focus(-1);
2944        d.log("three");
2945        assert_eq!(d.focus_row, 0);
2946    }
2947
2948    #[test]
2949    fn exception_filter_defaults() {
2950        let caps = json!({
2951            "exceptionBreakpointFilters": [
2952                {"filter": "raised", "default": false},
2953                {"filter": "uncaught", "default": true}
2954            ]
2955        });
2956        assert_eq!(pick_exception_filters(&caps), vec!["uncaught"]);
2957        let caps2 = json!({
2958            "exceptionBreakpointFilters": [
2959                {"filter": "raised"},
2960                {"filter": "uncaught"}
2961            ]
2962        });
2963        assert_eq!(pick_exception_filters(&caps2), vec!["uncaught"]);
2964        assert!(pick_exception_filters(&json!({})).is_empty());
2965    }
2966}