Skip to main content

stryke/
debugger.rs

1//! Interactive debugger for stryke programs.
2//!
3//! Provides breakpoint-based debugging with single-stepping, variable inspection,
4//! and call stack display. Two front-ends share this state machine:
5//!
6//! * **TTY/CLI** (default, via `-d`) — `prompt()` reads commands from stdin and
7//!   writes to stderr. Same UX as `perl -d`.
8//! * **DAP** (via `--dap`) — `prompt()` instead routes through
9//!   [`crate::dap::DapShared`], emitting `stopped` events and condvar-waiting
10//!   for resume. Stdin is owned by the DAP reader thread; the debugger never
11//!   touches it in DAP mode.
12//!
13//! The split is per-instance, not compile-time: `Debugger::set_dap_backend`
14//! configures the DAP backend. With no backend set, TTY behavior runs.
15
16use std::collections::HashSet;
17use std::io::{self, BufRead, Write};
18use std::sync::{Arc, Mutex};
19
20use crate::scope::Scope;
21use crate::value::StrykeValue;
22
23/// Debugger state, shared between VM and interpreter.
24pub struct Debugger {
25    /// Breakpoints by line number.
26    breakpoints: HashSet<usize>,
27    /// Breakpoints by subroutine name.
28    sub_breakpoints: HashSet<String>,
29    /// Single-step mode: stop at every statement/opcode.
30    step_mode: bool,
31    /// Step-over mode: stop at next statement at same or lower call depth.
32    step_over_depth: Option<usize>,
33    /// Step-out mode: stop when returning to this call depth.
34    step_out_depth: Option<usize>,
35    /// Current call depth for step-over/step-out.
36    call_depth: usize,
37    /// Last line we stopped at (avoid repeated stops on same line).
38    last_stop_line: Option<usize>,
39    /// Call depth at the last stop. Paired with [`Self::last_stop_line`] so the
40    /// same-line guard treats a depth change (sub entered or returned) as
41    /// forward progress — stepIn must fire when we enter a sub even if the
42    /// first opcode inside reports the same source line as the call site.
43    last_stop_depth: usize,
44    /// Current source file name.
45    pub file: String,
46    /// Source lines (for display).
47    source_lines: Vec<String>,
48    /// Whether debugger is enabled.
49    enabled: bool,
50    /// Watch expressions (variable names to display on each stop).
51    watches: Vec<String>,
52    /// Command history.
53    history: Vec<String>,
54    /// Optional DAP backend. When `Some`, `prompt()` emits a `stopped` event
55    /// and condvar-waits instead of reading from stdin.
56    dap_backend: Option<DapBackendHandle>,
57}
58
59/// Opaque handle into [`crate::dap::DapShared`] + the shared breakpoint state.
60/// Kept as `Arc<dyn Any>` so debugger.rs does not depend on dap.rs at compile
61/// time (and tests work without DAP).
62pub struct DapBackendHandle {
63    pub shared: Arc<crate::dap::DapShared>,
64    pub bp_state: Arc<Mutex<crate::dap::BreakpointState>>,
65}
66
67impl Default for Debugger {
68    fn default() -> Self {
69        Self::new()
70    }
71}
72
73impl Debugger {
74    pub fn new() -> Self {
75        Self {
76            breakpoints: HashSet::new(),
77            sub_breakpoints: HashSet::new(),
78            step_mode: true,
79            step_over_depth: None,
80            step_out_depth: None,
81            call_depth: 0,
82            last_stop_line: None,
83            last_stop_depth: 0,
84            file: String::new(),
85            source_lines: Vec::new(),
86            enabled: true,
87            watches: Vec::new(),
88            history: Vec::new(),
89            dap_backend: None,
90        }
91    }
92
93    /// Add a line breakpoint programmatically (used by the DAP server before
94    /// the VM starts). TTY users add via the `b N` command.
95    pub fn add_breakpoint_line(&mut self, line: usize) {
96        self.breakpoints.insert(line);
97    }
98
99    /// Add a function breakpoint programmatically.
100    pub fn add_breakpoint_sub(&mut self, name: &str) {
101        self.sub_breakpoints.insert(name.to_string());
102    }
103
104    /// Clear every line breakpoint (the DAP server re-sends the full set on
105    /// every `setBreakpoints` request).
106    pub fn clear_line_breakpoints(&mut self) {
107        self.breakpoints.clear();
108    }
109
110    /// Replace the entire line-breakpoint set in one shot.
111    pub fn set_line_breakpoints(&mut self, lines: &[usize]) {
112        self.breakpoints = lines.iter().copied().collect();
113    }
114
115    /// Toggle step-mode (controls whether `should_stop` returns true on every
116    /// new line). The DAP server flips this in response to `next` / `stepIn`.
117    pub fn set_step_mode(&mut self, on: bool) {
118        self.step_mode = on;
119    }
120
121    /// Request a step-over from the next stop.
122    pub fn request_step_over(&mut self) {
123        self.step_over_depth = Some(self.call_depth);
124    }
125
126    /// Request a step-out from the next stop.
127    pub fn request_step_out(&mut self) {
128        self.step_out_depth = Some(self.call_depth);
129    }
130
131    /// Install a DAP backend. After this call, `prompt()` will route through
132    /// the DAP server instead of TTY.
133    pub fn set_dap_backend(
134        &mut self,
135        shared: Arc<crate::dap::DapShared>,
136        bp_state: Arc<Mutex<crate::dap::BreakpointState>>,
137    ) {
138        self.dap_backend = Some(DapBackendHandle { shared, bp_state });
139    }
140
141    /// True when this debugger instance is wired to a DAP front-end.
142    #[inline]
143    pub fn is_dap(&self) -> bool {
144        self.dap_backend.is_some()
145    }
146
147    /// Snapshot helper: current breakpoint lines, sorted.
148    pub fn breakpoint_lines(&self) -> Vec<usize> {
149        let mut v: Vec<usize> = self.breakpoints.iter().copied().collect();
150        v.sort_unstable();
151        v
152    }
153
154    /// Build a [`crate::dap::PauseSnapshot`] for the current stop. Used only
155    /// in DAP mode; harmless in TTY mode (returns an empty default).
156    fn build_snapshot(
157        &self,
158        line: usize,
159        scope: &Scope,
160        call_stack: &[(String, usize)],
161        reason: &str,
162    ) -> crate::dap::PauseSnapshot {
163        let mut frames: Vec<crate::dap::FrameSnap> = Vec::new();
164        // Innermost (current) frame first
165        frames.push(crate::dap::FrameSnap {
166            name: "<current>".to_string(),
167            file: self.file.clone(),
168            line,
169        });
170        for (name, l) in call_stack.iter().rev() {
171            frames.push(crate::dap::FrameSnap {
172                name: name.clone(),
173                file: self.file.clone(),
174                line: *l,
175            });
176        }
177        let mut var_ref_map = std::collections::HashMap::new();
178        let locals = crate::dap::capture_locals_with_map(scope, &mut var_ref_map);
179        crate::dap::PauseSnapshot {
180            file: self.file.clone(),
181            line,
182            reason: reason.to_string(),
183            frames,
184            locals,
185            globals: Vec::new(),
186            var_ref_map,
187        }
188    }
189
190    /// Load source for display in debugger.
191    pub fn load_source(&mut self, source: &str) {
192        self.source_lines = source.lines().map(String::from).collect();
193    }
194
195    /// Set source file name.
196    pub fn set_file(&mut self, file: &str) {
197        self.file = file.to_string();
198    }
199
200    /// Check if debugger should stop at this line.
201    pub fn should_stop(&mut self, line: usize) -> bool {
202        if !self.enabled {
203            return false;
204        }
205
206        if std::env::var("STRYKE_DBG_TRACE").is_ok() {
207            eprintln!(
208                "[ss] line={} bp_set={:?}",
209                line,
210                self.breakpoints.iter().collect::<Vec<_>>()
211            );
212        }
213
214        // Line 0 is the VM's "no source mapping" sentinel — synthetic teardown
215        // ops, prelude bytecode, etc. Never user-visible. Skip silently or the
216        // IDE pauses on an invalid frame (no Variables, no source highlight).
217        if line == 0 {
218            return false;
219        }
220
221        // Same-line guard: skip when we haven't made progress since the last
222        // stop. Progress = the source line moved OR the call depth changed
223        // (sub entered/returned). Without the depth half, stepIn would fire
224        // on the next opcode of the call site (same line) instead of the
225        // first opcode inside the sub.
226        if self.last_stop_line == Some(line) && self.call_depth == self.last_stop_depth {
227            return false;
228        }
229
230        // Check breakpoints
231        if self.breakpoints.contains(&line) {
232            return true;
233        }
234
235        // Check step mode
236        if self.step_mode {
237            return true;
238        }
239
240        // Check step-over (stop at same or lower depth)
241        if let Some(depth) = self.step_over_depth {
242            if self.call_depth <= depth {
243                self.step_over_depth = None;
244                return true;
245            }
246        }
247
248        // Check step-out (stop when returning)
249        if let Some(depth) = self.step_out_depth {
250            if self.call_depth < depth {
251                self.step_out_depth = None;
252                return true;
253            }
254        }
255
256        false
257    }
258
259    /// Check if we should stop at subroutine entry.
260    pub fn should_stop_at_sub(&self, name: &str) -> bool {
261        self.enabled && self.sub_breakpoints.contains(name)
262    }
263
264    /// Notify debugger of subroutine call.
265    pub fn enter_sub(&mut self, _name: &str) {
266        self.call_depth += 1;
267    }
268
269    /// Notify debugger of subroutine return.
270    pub fn leave_sub(&mut self) {
271        self.call_depth = self.call_depth.saturating_sub(1);
272    }
273
274    /// Interactive debugger prompt. Returns true to continue, false to quit.
275    ///
276    /// Two paths:
277    /// * **DAP** — emit a `stopped` event with a snapshot, condvar-wait for
278    ///   the next resume command, apply step-mode flags from the shared
279    ///   breakpoint state, and return.
280    /// * **TTY** — original `perl -d`-style REPL on stdin/stderr.
281    pub fn prompt(
282        &mut self,
283        line: usize,
284        scope: &Scope,
285        call_stack: &[(String, usize)],
286    ) -> DebugAction {
287        self.last_stop_line = Some(line);
288        self.last_stop_depth = self.call_depth;
289        self.step_mode = false;
290
291        // DAP front-end: route through the shared state, return early.
292        if let Some(backend) = self.dap_backend.as_ref() {
293            let reason = if self.breakpoints.contains(&line) {
294                "breakpoint"
295            } else {
296                "step"
297            };
298            let snap = self.build_snapshot(line, scope, call_stack, reason);
299            let shared = backend.shared.clone();
300            let bp = backend.bp_state.clone();
301            let action = shared.pause(snap);
302            // Read any step-kind set by the DAP reader thread.
303            if let Ok(mut g) = bp.lock() {
304                if let Some(kind) = g.pending_step.take() {
305                    match kind {
306                        crate::dap::StepKind::Over => self.step_over_depth = Some(self.call_depth),
307                        crate::dap::StepKind::Into => self.step_mode = true,
308                        crate::dap::StepKind::Out => self.step_out_depth = Some(self.call_depth),
309                    }
310                }
311                // Sync line breakpoints (client may have sent new ones while paused)
312                if let Some(lines) = g.line_breakpoints.get(&self.file) {
313                    let lines = lines.clone();
314                    self.set_line_breakpoints(&lines);
315                }
316            }
317            return action;
318        }
319
320        // Print location and source context
321        self.print_location(line);
322        self.print_watches(scope);
323
324        loop {
325            eprint!("  DB<{}> ", self.history.len() + 1);
326            io::stderr().flush().ok();
327
328            let mut input = String::new();
329            if io::stdin().lock().read_line(&mut input).is_err() {
330                return DebugAction::Quit;
331            }
332            let input = input.trim();
333
334            if input.is_empty() {
335                // Repeat last command or step
336                if let Some(last) = self.history.last().cloned() {
337                    return self.execute_command(&last, line, scope, call_stack);
338                }
339                self.step_mode = true;
340                return DebugAction::Continue;
341            }
342
343            self.history.push(input.to_string());
344            let action = self.execute_command(input, line, scope, call_stack);
345            if !matches!(action, DebugAction::Prompt) {
346                return action;
347            }
348        }
349    }
350
351    fn execute_command(
352        &mut self,
353        input: &str,
354        line: usize,
355        scope: &Scope,
356        call_stack: &[(String, usize)],
357    ) -> DebugAction {
358        let parts: Vec<&str> = input.splitn(2, ' ').collect();
359        let cmd = parts[0];
360        let arg = parts.get(1).map(|s| s.trim()).unwrap_or("");
361
362        match cmd {
363            // Step commands
364            "s" | "step" | "n" | "next" => {
365                self.step_mode = true;
366                DebugAction::Continue
367            }
368            "o" | "over" => {
369                self.step_over_depth = Some(self.call_depth);
370                DebugAction::Continue
371            }
372            "out" | "finish" | "r" => {
373                self.step_out_depth = Some(self.call_depth);
374                DebugAction::Continue
375            }
376            "c" | "cont" | "continue" => {
377                self.step_mode = false;
378                DebugAction::Continue
379            }
380
381            // Breakpoints
382            "b" | "break" => {
383                if arg.is_empty() {
384                    self.breakpoints.insert(line);
385                    eprintln!("Breakpoint set at line {}", line);
386                } else if let Ok(n) = arg.parse::<usize>() {
387                    self.breakpoints.insert(n);
388                    eprintln!("Breakpoint set at line {}", n);
389                } else {
390                    self.sub_breakpoints.insert(arg.to_string());
391                    eprintln!("Breakpoint set at fn {}", arg);
392                }
393                DebugAction::Prompt
394            }
395            "B" | "delete" => {
396                if arg.is_empty() || arg == "*" {
397                    self.breakpoints.clear();
398                    self.sub_breakpoints.clear();
399                    eprintln!("All breakpoints deleted");
400                } else if let Ok(n) = arg.parse::<usize>() {
401                    self.breakpoints.remove(&n);
402                    eprintln!("Breakpoint at line {} deleted", n);
403                } else {
404                    self.sub_breakpoints.remove(arg);
405                    eprintln!("Breakpoint at fn {} deleted", arg);
406                }
407                DebugAction::Prompt
408            }
409            "L" | "breakpoints" => {
410                if self.breakpoints.is_empty() && self.sub_breakpoints.is_empty() {
411                    eprintln!("No breakpoints set");
412                } else {
413                    eprintln!("Breakpoints:");
414                    for &bp in &self.breakpoints {
415                        eprintln!("  line {}", bp);
416                    }
417                    for bp in &self.sub_breakpoints {
418                        eprintln!("  fn {}", bp);
419                    }
420                }
421                DebugAction::Prompt
422            }
423
424            // Inspection
425            "p" | "print" | "x" => {
426                if arg.is_empty() {
427                    eprintln!("Usage: p <var> (e.g., p $x, p @arr, p %hash)");
428                } else {
429                    self.print_variable(arg, scope);
430                }
431                DebugAction::Prompt
432            }
433            "V" | "vars" => {
434                self.print_all_vars(scope);
435                DebugAction::Prompt
436            }
437            "w" | "watch" => {
438                if arg.is_empty() {
439                    if self.watches.is_empty() {
440                        eprintln!("No watches set");
441                    } else {
442                        eprintln!("Watches: {}", self.watches.join(", "));
443                    }
444                } else {
445                    self.watches.push(arg.to_string());
446                    eprintln!("Watching: {}", arg);
447                }
448                DebugAction::Prompt
449            }
450            "W" => {
451                if arg.is_empty() || arg == "*" {
452                    self.watches.clear();
453                    eprintln!("All watches cleared");
454                } else {
455                    self.watches.retain(|w| w != arg);
456                    eprintln!("Watch {} removed", arg);
457                }
458                DebugAction::Prompt
459            }
460
461            // Stack
462            "T" | "stack" | "bt" | "backtrace" => {
463                self.print_stack(call_stack, line);
464                DebugAction::Prompt
465            }
466
467            // Source listing
468            "l" | "list" => {
469                let target = if arg.is_empty() {
470                    line
471                } else {
472                    arg.parse().unwrap_or(line)
473                };
474                self.list_source(target, 10);
475                DebugAction::Prompt
476            }
477            "." => {
478                self.print_location(line);
479                DebugAction::Prompt
480            }
481
482            // Control
483            "q" | "quit" | "exit" => DebugAction::Quit,
484            "h" | "help" | "?" => {
485                self.print_help();
486                DebugAction::Prompt
487            }
488            "D" | "disable" => {
489                self.enabled = false;
490                eprintln!("Debugger disabled (use -d to re-enable on next run)");
491                DebugAction::Continue
492            }
493
494            _ => {
495                eprintln!("Unknown command: {}. Type 'h' for help.", cmd);
496                DebugAction::Prompt
497            }
498        }
499    }
500
501    fn print_location(&self, line: usize) {
502        let file_display = if self.file.is_empty() {
503            "<eval>"
504        } else {
505            &self.file
506        };
507        eprintln!();
508        eprintln!("{}:{}", file_display, line);
509
510        // Print surrounding lines
511        let start = line.saturating_sub(2);
512        let end = (line + 2).min(self.source_lines.len());
513        for i in start..end {
514            let marker = if i + 1 == line { "==>" } else { "   " };
515            if let Some(src) = self.source_lines.get(i) {
516                eprintln!("{} {:4}:  {}", marker, i + 1, src);
517            }
518        }
519    }
520
521    fn print_watches(&self, scope: &Scope) {
522        if self.watches.is_empty() {
523            return;
524        }
525        eprintln!("Watches:");
526        for w in &self.watches {
527            eprint!("  {} = ", w);
528            self.print_variable(w, scope);
529        }
530    }
531
532    fn print_variable(&self, var: &str, scope: &Scope) {
533        let var = var.trim();
534        if let Some(name) = var.strip_prefix('$') {
535            let val = scope.get_scalar(name);
536            eprintln!("{}", format_value(&val));
537        } else if let Some(name) = var.strip_prefix('@') {
538            let val = scope.get_array(name);
539            eprintln!(
540                "({})",
541                val.iter().map(format_value).collect::<Vec<_>>().join(", ")
542            );
543        } else if let Some(name) = var.strip_prefix('%') {
544            let val = scope.get_hash(name);
545            let pairs: Vec<String> = val
546                .iter()
547                .map(|(k, v)| format!("{} => {}", k, format_value(v)))
548                .collect();
549            eprintln!("({})", pairs.join(", "));
550        } else {
551            // Assume scalar
552            let val = scope.get_scalar(var);
553            eprintln!("{}", format_value(&val));
554        }
555    }
556
557    fn print_all_vars(&self, scope: &Scope) {
558        let vars = scope.all_scalar_names();
559        if vars.is_empty() {
560            eprintln!("No variables in scope");
561            return;
562        }
563        eprintln!("Variables:");
564        for name in vars {
565            if name.starts_with('^') || name.starts_with('_') && name.len() > 2 {
566                continue; // Skip special vars
567            }
568            let val = scope.get_scalar(&name);
569            if !val.is_undef() {
570                eprintln!("  ${} = {}", name, format_value(&val));
571            }
572        }
573    }
574
575    fn print_stack(&self, call_stack: &[(String, usize)], current_line: usize) {
576        eprintln!("Call stack:");
577        if call_stack.is_empty() {
578            eprintln!("  #0  <main> at line {}", current_line);
579        } else {
580            for (i, (name, line)) in call_stack.iter().enumerate().rev() {
581                eprintln!("  #{}  {} at line {}", call_stack.len() - i, name, line);
582            }
583            eprintln!("  #0  <current> at line {}", current_line);
584        }
585    }
586
587    fn list_source(&self, center: usize, radius: usize) {
588        let start = center.saturating_sub(radius);
589        let end = (center + radius).min(self.source_lines.len());
590        for i in start..end {
591            let marker = if i + 1 == center { "==>" } else { "   " };
592            let bp = if self.breakpoints.contains(&(i + 1)) {
593                "b"
594            } else {
595                " "
596            };
597            if let Some(src) = self.source_lines.get(i) {
598                eprintln!("{}{} {:4}:  {}", marker, bp, i + 1, src);
599            }
600        }
601    }
602
603    fn print_help(&self) {
604        eprintln!(
605            r#"
606Debugger Commands:
607  s, step, n, next    Step to next statement
608  o, over             Step over (don't descend into subs)
609  out, finish, r      Step out (run until sub returns)
610  c, cont, continue   Continue execution
611
612  b [line|sub]        Set breakpoint (current line if no arg)
613  B [line|sub|*]      Delete breakpoint(s)
614  L, breakpoints      List all breakpoints
615
616  p, print, x <var>   Print variable ($x, @arr, %hash)
617  V, vars             Print all variables in scope
618  w <var>             Add watch expression
619  W [var|*]           Remove watch expression(s)
620
621  T, stack, bt        Print call stack backtrace
622  l [line]            List source around line
623  .                   Show current location
624
625  q, quit, exit       Quit program
626  h, help, ?          Show this help
627  D, disable          Disable debugger (continue without stops)
628
629  <Enter>             Repeat last command or step
630"#
631        );
632    }
633}
634
635/// Action to take after debugger prompt.
636#[derive(Debug, Clone, Copy, PartialEq, Eq)]
637pub enum DebugAction {
638    Continue,
639    Quit,
640    Prompt,
641}
642
643pub(crate) fn format_value(val: &StrykeValue) -> String {
644    if val.is_undef() {
645        "undef".to_string()
646    } else if let Some(s) = val.as_str() {
647        if s.parse::<f64>().is_ok() {
648            s.to_string()
649        } else {
650            format!("\"{}\"", s.escape_default())
651        }
652    } else if let Some(n) = val.as_integer() {
653        n.to_string()
654    } else if let Some(f) = val.as_float() {
655        f.to_string()
656    } else if val.as_array_ref().is_some() || val.as_array_vec().is_some() {
657        let list = val.to_list();
658        let items: Vec<String> = list.iter().map(format_value).collect();
659        format!("[{}]", items.join(", "))
660    } else if val.as_hash_ref().is_some() {
661        if let Some(map) = val.as_hash_map() {
662            let pairs: Vec<String> = map
663                .iter()
664                .map(|(k, v)| format!("{} => {}", k, format_value(v)))
665                .collect();
666            format!("{{{}}}", pairs.join(", "))
667        } else {
668            "HASH(?)".to_string()
669        }
670    } else if val.as_code_ref().is_some() {
671        "CODE(...)".to_string()
672    } else {
673        val.type_name()
674    }
675}
676
677#[cfg(test)]
678mod tests {
679    use super::*;
680
681    #[test]
682    fn debugger_new_defaults() {
683        let dbg = Debugger::new();
684        assert!(dbg.breakpoints.is_empty());
685        assert!(dbg.sub_breakpoints.is_empty());
686        assert!(dbg.step_mode);
687        assert!(dbg.enabled);
688        assert!(dbg.watches.is_empty());
689        assert_eq!(dbg.call_depth, 0);
690    }
691
692    #[test]
693    fn debugger_load_source_splits_lines() {
694        let mut dbg = Debugger::new();
695        dbg.load_source("line1\nline2\nline3");
696        assert_eq!(dbg.source_lines.len(), 3);
697        assert_eq!(dbg.source_lines[0], "line1");
698        assert_eq!(dbg.source_lines[2], "line3");
699    }
700
701    #[test]
702    fn debugger_set_file() {
703        let mut dbg = Debugger::new();
704        dbg.set_file("test.pl");
705        assert_eq!(dbg.file, "test.pl");
706    }
707
708    #[test]
709    fn debugger_should_stop_at_breakpoint() {
710        let mut dbg = Debugger::new();
711        dbg.step_mode = false;
712        dbg.breakpoints.insert(10);
713        assert!(dbg.should_stop(10));
714        assert!(!dbg.should_stop(11));
715    }
716
717    #[test]
718    fn debugger_should_stop_in_step_mode() {
719        let mut dbg = Debugger::new();
720        dbg.step_mode = true;
721        assert!(dbg.should_stop(1));
722        assert!(dbg.should_stop(999));
723    }
724
725    #[test]
726    fn debugger_should_stop_disabled() {
727        let mut dbg = Debugger::new();
728        dbg.enabled = false;
729        dbg.step_mode = true;
730        assert!(!dbg.should_stop(1));
731    }
732
733    #[test]
734    fn debugger_should_stop_at_sub() {
735        let mut dbg = Debugger::new();
736        dbg.sub_breakpoints.insert("foo".to_string());
737        assert!(dbg.should_stop_at_sub("foo"));
738        assert!(!dbg.should_stop_at_sub("bar"));
739    }
740
741    #[test]
742    fn debugger_enter_leave_sub_tracks_depth() {
743        let mut dbg = Debugger::new();
744        assert_eq!(dbg.call_depth, 0);
745        dbg.enter_sub("foo");
746        assert_eq!(dbg.call_depth, 1);
747        dbg.enter_sub("bar");
748        assert_eq!(dbg.call_depth, 2);
749        dbg.leave_sub();
750        assert_eq!(dbg.call_depth, 1);
751        dbg.leave_sub();
752        assert_eq!(dbg.call_depth, 0);
753        dbg.leave_sub();
754        assert_eq!(dbg.call_depth, 0);
755    }
756
757    #[test]
758    fn debugger_step_over_depth() {
759        let mut dbg = Debugger::new();
760        dbg.step_mode = false;
761        dbg.enter_sub("outer");
762        dbg.step_over_depth = Some(1);
763        dbg.enter_sub("inner");
764        assert!(!dbg.should_stop(5));
765        dbg.leave_sub();
766        assert!(dbg.should_stop(6));
767        assert!(dbg.step_over_depth.is_none());
768    }
769
770    #[test]
771    fn debugger_step_out_depth() {
772        let mut dbg = Debugger::new();
773        dbg.step_mode = false;
774        dbg.enter_sub("outer");
775        dbg.enter_sub("inner");
776        dbg.step_out_depth = Some(2);
777        assert!(!dbg.should_stop(5));
778        dbg.leave_sub();
779        assert!(dbg.should_stop(6));
780        assert!(dbg.step_out_depth.is_none());
781    }
782
783    #[test]
784    fn debugger_avoids_repeated_stops_on_same_line() {
785        let mut dbg = Debugger::new();
786        dbg.step_mode = false;
787        dbg.breakpoints.insert(10);
788        assert!(dbg.should_stop(10));
789        dbg.last_stop_line = Some(10);
790        assert!(!dbg.should_stop(10));
791    }
792
793    /// Regression for the "step-in fires on the same line" bug. The same-
794    /// line guard must treat a depth change (sub entered or returned) as
795    /// forward progress, otherwise step-in lands on the next opcode of the
796    /// call site (which has the same source line as the call) and the
797    /// user has to click step-in twice to actually enter the sub.
798    #[test]
799    fn same_line_guard_yields_to_depth_change_on_step_in() {
800        let mut dbg = Debugger::new();
801        // Stopped at line 10, depth 0 (caller's frame).
802        dbg.last_stop_line = Some(10);
803        dbg.last_stop_depth = 0;
804        // step-in arms step_mode.
805        dbg.step_mode = true;
806        // Same line, same depth (still in caller) → skip.
807        assert!(!dbg.should_stop(10));
808        // Depth bumps (entered the sub) → fire even on same source line.
809        dbg.enter_sub("callee");
810        assert!(dbg.should_stop(10));
811    }
812
813    /// And the inverse — when call_depth shrinks past `step_out_depth`,
814    /// step-out should fire even though we may land on the same line as
815    /// the call site (when callee tail-returns at the call line).
816    #[test]
817    fn step_out_fires_when_returning_to_same_line() {
818        let mut dbg = Debugger::new();
819        // Inside callee at depth 1, stopped at line 5.
820        dbg.enter_sub("callee");
821        dbg.last_stop_line = Some(5);
822        dbg.last_stop_depth = 1;
823        dbg.step_out_depth = Some(1);
824        // Still inside callee — don't fire.
825        assert!(!dbg.should_stop(5));
826        // Callee returned — depth drops.
827        dbg.leave_sub();
828        // Same source line as the call site but depth dropped → fire.
829        assert!(dbg.should_stop(5));
830    }
831
832    /// Step-over requires the *exact* depth-aware guard the production
833    /// debugger uses (`call_depth <= step_over_depth`). The earlier
834    /// non-depth guard let step-over follow execution into UDFs because
835    /// call_depth never moved.
836    #[test]
837    fn step_over_skips_into_nested_frame_and_resumes_after_return() {
838        let mut dbg = Debugger::new();
839        dbg.step_mode = false;
840        // Step-over from the call site (depth 0) at line 10.
841        dbg.step_over_depth = Some(0);
842        dbg.enter_sub("callee");
843        // Deeper than the request — skip every line inside the sub.
844        assert!(!dbg.should_stop(20));
845        assert!(!dbg.should_stop(21));
846        // Sub returns → depth back to 0 → fire at the line after the
847        // call.
848        dbg.leave_sub();
849        assert!(dbg.should_stop(11));
850    }
851
852    #[test]
853    fn format_value_undef() {
854        assert_eq!(format_value(&StrykeValue::UNDEF), "undef");
855    }
856
857    #[test]
858    fn format_value_integer() {
859        assert_eq!(format_value(&StrykeValue::integer(42)), "42");
860        assert_eq!(format_value(&StrykeValue::integer(-100)), "-100");
861    }
862
863    #[test]
864    fn format_value_float() {
865        // Use a non-PI-approximation literal to dodge clippy::approx_constant.
866        let f = format_value(&StrykeValue::float(2.71));
867        assert!(f.starts_with("2.71"));
868    }
869
870    #[test]
871    fn format_value_string() {
872        assert_eq!(
873            format_value(&StrykeValue::string("hello".into())),
874            "\"hello\""
875        );
876    }
877
878    #[test]
879    fn format_value_numeric_string() {
880        assert_eq!(format_value(&StrykeValue::string("42".into())), "42");
881        assert_eq!(format_value(&StrykeValue::string("3.14".into())), "3.14");
882    }
883
884    #[test]
885    fn format_value_array() {
886        let arr = StrykeValue::array(vec![
887            StrykeValue::integer(1),
888            StrykeValue::integer(2),
889            StrykeValue::integer(3),
890        ]);
891        assert_eq!(format_value(&arr), "[1, 2, 3]");
892    }
893
894    #[test]
895    fn debug_action_eq() {
896        assert_eq!(DebugAction::Continue, DebugAction::Continue);
897        assert_ne!(DebugAction::Continue, DebugAction::Quit);
898        assert_ne!(DebugAction::Quit, DebugAction::Prompt);
899    }
900}