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