Skip to main content

perl_dap/stack/
parser.rs

1//! Parser for Perl debugger stack trace output.
2//!
3//! This module provides utilities for parsing stack trace output from the Perl debugger
4//! into structured [`StackFrame`] representations.
5
6use super::{Source, StackFrame, StackFramePresentationHint};
7use once_cell::sync::Lazy;
8use regex::Regex;
9use thiserror::Error;
10
11/// Errors that can occur during stack trace parsing.
12#[derive(Debug, Error)]
13pub enum StackParseError {
14    /// The input format was not recognized.
15    #[error("unrecognized stack frame format: {0}")]
16    UnrecognizedFormat(String),
17
18    /// A regex pattern failed to compile.
19    #[error("regex error: {0}")]
20    RegexError(#[from] regex::Error),
21}
22
23// Compiled regex patterns for stack trace parsing.
24// These patterns are extracted from the perl-dap debug_adapter.rs implementation.
25// Stored as Results to avoid panics; compile failure treated as "no match".
26
27/// Pattern for parsing context information from debugger output.
28/// Matches formats like:
29/// - `Package::func(file.pl:42):`
30/// - `main::(script.pl):42:`
31static CONTEXT_RE: Lazy<Result<Regex, regex::Error>> = Lazy::new(|| {
32    Regex::new(
33        r"^(?:(?P<func>[A-Za-z_][\w:]*+?)::(?:\((?P<file>[^:)]+):(?P<line>\d+)\):?|__ANON__)|main::(?:\((?P<file2_paren>[^)]+)\)|(?P<file2>[^:]+)):(?P<line2>\d+):?)",
34    )
35});
36
37/// Pattern for parsing standard stack frame output.
38/// Matches formats like:
39/// - `  @ = Package::func called from file 'path/file.pl' line 42`
40/// - `  #0  main::foo at script.pl line 10`
41static STACK_FRAME_RE: Lazy<Result<Regex, regex::Error>> = Lazy::new(|| {
42    Regex::new(
43        r"^\s*#?\s*(?P<frame>\d+)?\s+(?P<func>[A-Za-z_][\w:]*+?)(?:\s+called)?\s+at\s+(?P<file>.+?)\s+line\s+(?P<line>\d+)",
44    )
45});
46
47/// Pattern for Perl debugger 'T' command output (verbose backtrace).
48/// Matches formats like:
49/// - `$ = My::Module::method(arg1, arg2) called from file `/path/file.pm' line 123`
50static VERBOSE_FRAME_RE: Lazy<Result<Regex, regex::Error>> = Lazy::new(|| {
51    Regex::new(
52        r"^\s*[\$\@\.]\s*=\s*(?P<func>[A-Za-z_][\w:]*+?)\((?P<args>.*?)\)\s+called\s+from\s+file\s+[`'](?P<file>[^'`]+)[`']\s+line\s+(?P<line>\d+)",
53    )
54});
55
56/// Pattern for simple 'T' command format.
57/// Matches formats like:
58/// - `. = My::Module::method() called from '-e' line 1`
59static SIMPLE_FRAME_RE: Lazy<Result<Regex, regex::Error>> = Lazy::new(|| {
60    Regex::new(
61        r"^\s*[\$\@\.]\s*=\s*(?P<func>[A-Za-z_][\w:]*+?)\s*\(\)\s+called\s+from\s+[`'](?P<file>[^'`]+)[`']\s+line\s+(?P<line>\d+)",
62    )
63});
64
65/// Pattern for eval context in stack traces.
66/// Matches formats like:
67/// - `(eval 10)[/path/file.pm:42]`
68static EVAL_CONTEXT_RE: Lazy<Result<Regex, regex::Error>> =
69    Lazy::new(|| Regex::new(r"^\(eval\s+(?P<eval_num>\d+)\)\[(?P<file>[^\]:]+):(?P<line>\d+)\]"));
70
71/// Pattern for extracting a best-effort function name from stack-like lines
72/// that do not include source location information.
73static UNKNOWN_FRAME_NAME_RE: Lazy<Result<Regex, regex::Error>> = Lazy::new(|| {
74    Regex::new(r"^\s*(?:#\s*\d+\s+)?(?:[\$\@\.]\s*=\s*)?(?P<func>[A-Za-z_][\w:]*+?)\b")
75});
76
77// Accessor functions for regexes
78fn context_re() -> Option<&'static Regex> {
79    CONTEXT_RE.as_ref().ok()
80}
81fn stack_frame_re() -> Option<&'static Regex> {
82    STACK_FRAME_RE.as_ref().ok()
83}
84fn verbose_frame_re() -> Option<&'static Regex> {
85    VERBOSE_FRAME_RE.as_ref().ok()
86}
87fn simple_frame_re() -> Option<&'static Regex> {
88    SIMPLE_FRAME_RE.as_ref().ok()
89}
90fn eval_context_re() -> Option<&'static Regex> {
91    EVAL_CONTEXT_RE.as_ref().ok()
92}
93fn unknown_frame_name_re() -> Option<&'static Regex> {
94    UNKNOWN_FRAME_NAME_RE.as_ref().ok()
95}
96
97/// Parser for Perl debugger stack trace output.
98///
99/// This parser converts text output from the Perl debugger's stack trace
100/// commands (`T`, `y`, etc.) into structured [`StackFrame`] representations.
101#[derive(Debug, Default)]
102pub struct PerlStackParser {
103    /// Whether to include frames with no source location
104    include_unknown_frames: bool,
105    /// Whether to assign IDs automatically
106    auto_assign_ids: bool,
107    /// Starting ID used to reset auto-assignment for each new trace.
108    starting_id: i64,
109    /// Starting ID for auto-assignment
110    next_id: i64,
111}
112
113impl PerlStackParser {
114    /// Creates a new stack parser with default settings.
115    #[must_use]
116    pub fn new() -> Self {
117        Self { include_unknown_frames: false, auto_assign_ids: true, starting_id: 1, next_id: 1 }
118    }
119
120    /// Sets whether to include frames with no source location.
121    #[must_use]
122    pub fn with_unknown_frames(mut self, include: bool) -> Self {
123        self.include_unknown_frames = include;
124        self
125    }
126
127    /// Sets whether to auto-assign frame IDs.
128    #[must_use]
129    pub fn with_auto_ids(mut self, auto: bool) -> Self {
130        self.auto_assign_ids = auto;
131        self
132    }
133
134    /// Sets the starting ID for auto-assignment.
135    #[must_use]
136    pub fn with_starting_id(mut self, id: i64) -> Self {
137        self.starting_id = id;
138        self.next_id = id;
139        self
140    }
141
142    /// Parses a single stack frame line.
143    ///
144    /// # Arguments
145    ///
146    /// * `line` - A line from stack trace output
147    /// * `id` - The frame ID to assign (ignored if auto_assign_ids is true)
148    ///
149    /// # Returns
150    ///
151    /// A parsed [`StackFrame`] if the line matches a known format.
152    pub fn parse_frame(&mut self, line: &str, id: i64) -> Option<StackFrame> {
153        let line = line.trim();
154
155        // Try verbose backtrace format first
156        if let Some(caps) = verbose_frame_re().and_then(|re| re.captures(line)) {
157            return self.build_frame_from_captures(&caps, id, true);
158        }
159
160        // Try simple frame format
161        if let Some(caps) = simple_frame_re().and_then(|re| re.captures(line)) {
162            return self.build_frame_from_captures(&caps, id, false);
163        }
164
165        // Try standard stack frame format
166        if let Some(caps) = stack_frame_re().and_then(|re| re.captures(line)) {
167            return self.build_frame_from_captures(&caps, id, false);
168        }
169
170        // Try context format
171        if let Some(caps) = context_re().and_then(|re| re.captures(line)) {
172            return self.build_frame_from_context(&caps, id);
173        }
174
175        // Try eval context
176        if let Some(caps) = eval_context_re().and_then(|re| re.captures(line)) {
177            return self.build_eval_frame(&caps, id);
178        }
179
180        if self.include_unknown_frames && Self::looks_like_frame(line) {
181            return Some(self.build_unknown_frame(line, id));
182        }
183
184        None
185    }
186
187    fn resolve_frame_id(&mut self, provided_id: i64) -> i64 {
188        if self.auto_assign_ids {
189            let id = self.next_id;
190            self.next_id += 1;
191            id
192        } else {
193            provided_id
194        }
195    }
196
197    /// Builds a frame from regex captures.
198    fn build_frame_from_captures(
199        &mut self,
200        caps: &regex::Captures<'_>,
201        provided_id: i64,
202        _has_args: bool,
203    ) -> Option<StackFrame> {
204        let func = caps.name("func")?.as_str();
205        let file = caps.name("file")?.as_str();
206        let line_str = caps.name("line")?.as_str();
207        let line: i64 = line_str.parse().ok()?;
208
209        // Use frame number from capture if available, otherwise use provided/auto ID
210        let id = if self.auto_assign_ids {
211            self.resolve_frame_id(provided_id)
212        } else if let Some(frame_num) = caps.name("frame") {
213            frame_num.as_str().parse().unwrap_or(provided_id)
214        } else {
215            provided_id
216        };
217
218        let source = Source::new(file);
219        let frame = StackFrame::new(id, func, Some(source), line);
220
221        Some(frame)
222    }
223
224    /// Builds a frame from context regex captures.
225    fn build_frame_from_context(
226        &mut self,
227        caps: &regex::Captures<'_>,
228        provided_id: i64,
229    ) -> Option<StackFrame> {
230        // Get function name, defaulting to "main" if not present
231        let func = caps.name("func").map_or("main", |m| m.as_str());
232
233        // Get file from either capture group
234        let file = caps
235            .name("file")
236            .or_else(|| caps.name("file2_paren"))
237            .or_else(|| caps.name("file2"))?
238            .as_str();
239
240        // Reject blank/whitespace-only file captures (e.g. "main:: :42:")
241        if file.trim().is_empty() {
242            return None;
243        }
244
245        // Get line from either capture group
246        let line_str = caps.name("line").or_else(|| caps.name("line2"))?.as_str();
247        let line: i64 = line_str.parse().ok()?;
248
249        let id = self.resolve_frame_id(provided_id);
250
251        let source = Source::new(file);
252        let frame = StackFrame::new(id, func, Some(source), line);
253
254        Some(frame)
255    }
256
257    /// Builds an eval frame from regex captures.
258    fn build_eval_frame(
259        &mut self,
260        caps: &regex::Captures<'_>,
261        provided_id: i64,
262    ) -> Option<StackFrame> {
263        let eval_num = caps.name("eval_num")?.as_str();
264        let file = caps.name("file")?.as_str();
265        let line_str = caps.name("line")?.as_str();
266        let line: i64 = line_str.parse().ok()?;
267
268        let id = self.resolve_frame_id(provided_id);
269
270        let name = format!("(eval {})", eval_num);
271        let source = Source::new(file).with_origin("eval");
272        let frame = StackFrame::new(id, name, Some(source), line)
273            .with_presentation_hint(StackFramePresentationHint::Label);
274
275        Some(frame)
276    }
277
278    /// Builds a best-effort frame for stack-like lines missing source location.
279    fn build_unknown_frame(&mut self, line: &str, provided_id: i64) -> StackFrame {
280        let id = self.resolve_frame_id(provided_id);
281
282        let name = unknown_frame_name_re()
283            .and_then(|re| re.captures(line))
284            .and_then(|caps| caps.name("func"))
285            .map(|m| m.as_str().to_string())
286            .unwrap_or_else(|| "<unknown>".to_string());
287
288        StackFrame::new(id, name, None, 0)
289    }
290
291    /// Parses multi-line stack trace output.
292    ///
293    /// # Arguments
294    ///
295    /// * `output` - Multi-line debugger output from 'T' command
296    ///
297    /// # Returns
298    ///
299    /// A vector of parsed stack frames, ordered from innermost to outermost.
300    pub fn parse_stack_trace(&mut self, output: &str) -> Vec<StackFrame> {
301        // Reset auto-ID counter for new trace
302        if self.auto_assign_ids {
303            self.next_id = self.starting_id;
304        }
305
306        let frames: Vec<StackFrame> = output
307            .lines()
308            .filter_map(|line| {
309                let line = line.trim();
310                if line.is_empty() {
311                    return None;
312                }
313                self.parse_frame(line, 0)
314            })
315            .collect();
316
317        frames
318    }
319
320    /// Parses context information from a debugger prompt line.
321    ///
322    /// This is useful for determining the current execution position
323    /// from the debugger's status output.
324    ///
325    /// # Arguments
326    ///
327    /// * `line` - A line containing context information
328    ///
329    /// # Returns
330    ///
331    /// A tuple of (function, file, line) if parsed successfully.
332    pub fn parse_context(&self, line: &str) -> Option<(String, String, i64)> {
333        let line = line.trim();
334        if let Some(caps) = context_re().and_then(|re| re.captures(line)) {
335            let func = caps.name("func").map_or("main", |m| m.as_str()).to_string();
336            let file = caps
337                .name("file")
338                .or_else(|| caps.name("file2_paren"))
339                .or_else(|| caps.name("file2"))?
340                .as_str()
341                .to_string();
342            // Reject blank/whitespace-only file captures (e.g. "main:: :42:")
343            if file.trim().is_empty() {
344                return None;
345            }
346            let line_str = caps.name("line").or_else(|| caps.name("line2"))?.as_str();
347            let line: i64 = line_str.parse().ok()?;
348
349            return Some((func, file, line));
350        }
351
352        None
353    }
354
355    /// Determines if a line looks like a stack frame.
356    ///
357    /// This can be used for filtering lines before full parsing.
358    #[must_use]
359    pub fn looks_like_frame(line: &str) -> bool {
360        let line = line.trim();
361        let sigil_assignment_like = |sigil| line.starts_with(sigil) && line.contains(" = ");
362        let hash_frame_like = line.strip_prefix('#').is_some_and(|rest| {
363            rest.trim_start().chars().next().is_some_and(|c| c.is_ascii_digit())
364        });
365
366        // Check for common patterns
367        line.contains(" at ") && line.contains(" line ")
368            || line.contains(" called from ")
369            || sigil_assignment_like('$')
370            || sigil_assignment_like('@')
371            || sigil_assignment_like('.')
372            || hash_frame_like
373    }
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379
380    #[test]
381    fn test_parse_standard_frame() {
382        use perl_tdd_support::must_some;
383        let mut parser = PerlStackParser::new();
384        let line = "  #0  main::foo at script.pl line 10";
385        let frame = must_some(parser.parse_frame(line, 0));
386        assert_eq!(frame.name, "main::foo");
387        assert_eq!(frame.line, 10);
388        assert_eq!(frame.file_path(), Some("script.pl"));
389    }
390
391    #[test]
392    fn test_parse_verbose_frame() {
393        use perl_tdd_support::must_some;
394        let mut parser = PerlStackParser::new();
395        let line =
396            "$ = My::Module::method('arg1', 'arg2') called from file `/lib/My/Module.pm' line 42";
397        let frame = must_some(parser.parse_frame(line, 0));
398        assert_eq!(frame.name, "My::Module::method");
399        assert_eq!(frame.line, 42);
400        assert_eq!(frame.file_path(), Some("/lib/My/Module.pm"));
401    }
402
403    #[test]
404    fn test_parse_simple_frame() {
405        use perl_tdd_support::must_some;
406        let mut parser = PerlStackParser::new();
407        let line = ". = main::run() called from '-e' line 1";
408        let frame = must_some(parser.parse_frame(line, 0));
409        assert_eq!(frame.name, "main::run");
410        assert_eq!(frame.line, 1);
411    }
412
413    #[test]
414    fn test_parse_context_with_package() {
415        use perl_tdd_support::must_some;
416        let mut parser = PerlStackParser::new();
417        // Use the standard frame format which is well-supported
418        let line = "  #0  My::Package::subname at file.pl line 25";
419        let frame = must_some(parser.parse_frame(line, 0));
420        assert_eq!(frame.name, "My::Package::subname");
421        assert_eq!(frame.line, 25);
422    }
423
424    #[test]
425    fn test_parse_context_main() {
426        use perl_tdd_support::must_some;
427        let mut parser = PerlStackParser::new();
428        let line = "main::(script.pl):42:";
429        let frame = must_some(parser.parse_frame(line, 0));
430        assert_eq!(frame.name, "main");
431        assert_eq!(frame.line, 42);
432    }
433
434    #[test]
435    fn test_parse_context_main_with_spaces_in_file() {
436        use perl_tdd_support::must_some;
437        let mut parser = PerlStackParser::new();
438        let line = "main::(script with space.pl):42:";
439        let frame = must_some(parser.parse_frame(line, 0));
440        assert_eq!(frame.name, "main");
441        assert_eq!(frame.line, 42);
442        assert_eq!(frame.file_path(), Some("script with space.pl"));
443    }
444
445    #[test]
446    fn test_parse_context_main_without_parentheses_allows_spaces_in_file() {
447        use perl_tdd_support::must_some;
448        let mut parser = PerlStackParser::new();
449        let line = "main::script with space.pl:42:";
450        let frame = must_some(parser.parse_frame(line, 0));
451        assert_eq!(frame.name, "main");
452        assert_eq!(frame.line, 42);
453        assert_eq!(frame.file_path(), Some("script with space.pl"));
454    }
455
456    #[test]
457    fn test_parse_eval_context() {
458        use perl_tdd_support::must_some;
459        let mut parser = PerlStackParser::new();
460        let line = "(eval 10)[/path/to/file.pm:42]";
461        let frame = must_some(parser.parse_frame(line, 0));
462        assert!(frame.name.contains("eval 10"));
463        assert_eq!(frame.line, 42);
464        assert!(frame.source.as_ref().is_some_and(|s| s.is_eval()));
465    }
466
467    #[test]
468    fn test_parse_stack_trace_multi_line() {
469        let mut parser = PerlStackParser::new();
470        let output = r#"
471$ = My::Module::foo() called from file `/lib/My/Module.pm' line 10
472$ = My::Module::bar() called from file `/lib/My/Module.pm' line 20
473$ = main::run() called from file `script.pl' line 5
474"#;
475
476        let frames = parser.parse_stack_trace(output);
477
478        assert_eq!(frames.len(), 3);
479        assert_eq!(frames[0].name, "My::Module::foo");
480        assert_eq!(frames[1].name, "My::Module::bar");
481        assert_eq!(frames[2].name, "main::run");
482
483        // Check IDs are sequential
484        assert_eq!(frames[0].id, 1);
485        assert_eq!(frames[1].id, 2);
486        assert_eq!(frames[2].id, 3);
487    }
488
489    #[test]
490    fn test_parse_context_method() {
491        use perl_tdd_support::must_some;
492        let parser = PerlStackParser::new();
493
494        // The context regex expects formats like:
495        // Package::func::(file.pm:100): or main::(file.pm):100:
496        let result = must_some(parser.parse_context("main::(file.pm):100:"));
497
498        let (func, file, line) = result;
499        assert_eq!(func, "main");
500        assert_eq!(file, "file.pm");
501        assert_eq!(line, 100);
502    }
503
504    #[test]
505    fn test_parse_context_trims_surrounding_whitespace() {
506        use perl_tdd_support::must_some;
507        let parser = PerlStackParser::new();
508        let (func, file, line) = must_some(parser.parse_context("  main::(file.pm):100:  "));
509        assert_eq!(func, "main");
510        assert_eq!(file, "file.pm");
511        assert_eq!(line, 100);
512    }
513
514    #[test]
515    fn test_looks_like_frame() {
516        assert!(PerlStackParser::looks_like_frame("  #0  main::foo at script.pl line 10"));
517        assert!(PerlStackParser::looks_like_frame("# 0  main::foo at script.pl line 10"));
518        assert!(PerlStackParser::looks_like_frame("$ = foo() called from file 'x' line 1"));
519        assert!(!PerlStackParser::looks_like_frame("some random text"));
520        assert!(!PerlStackParser::looks_like_frame(""));
521    }
522
523    #[test]
524    fn test_auto_id_assignment() {
525        let mut parser = PerlStackParser::new().with_starting_id(100);
526
527        let frame1 = parser.parse_frame("  #0  main::foo at a.pl line 1", 0);
528        let frame2 = parser.parse_frame("  #1  main::bar at b.pl line 2", 0);
529
530        assert_eq!(frame1.map(|f| f.id), Some(100));
531        assert_eq!(frame2.map(|f| f.id), Some(101));
532    }
533
534    #[test]
535    fn test_parse_stack_trace_respects_custom_starting_id() {
536        let mut parser = PerlStackParser::new().with_starting_id(42);
537        let output = "  #0  main::foo at a.pl line 1\n  #1  main::bar at b.pl line 2";
538
539        let frames = parser.parse_stack_trace(output);
540
541        assert_eq!(frames.first().map(|f| f.id), Some(42));
542        assert_eq!(frames.get(1).map(|f| f.id), Some(43));
543    }
544
545    #[test]
546    fn test_parse_stack_trace_resets_to_custom_starting_id_between_calls() {
547        let mut parser = PerlStackParser::new().with_starting_id(7);
548        let output = "  #0  main::foo at a.pl line 1";
549
550        let first = parser.parse_stack_trace(output);
551        let second = parser.parse_stack_trace(output);
552
553        assert_eq!(first.first().map(|f| f.id), Some(7));
554        assert_eq!(second.first().map(|f| f.id), Some(7));
555    }
556
557    #[test]
558    fn test_manual_id_assignment() {
559        let mut parser = PerlStackParser::new().with_auto_ids(false);
560
561        let frame = parser.parse_frame("  #5  main::foo at a.pl line 1", 0);
562
563        // Should use the frame number from the capture
564        assert_eq!(frame.map(|f| f.id), Some(5));
565    }
566
567    #[test]
568    fn test_manual_id_assignment_for_context_and_eval_frames() {
569        use perl_tdd_support::must_some;
570        let mut parser = PerlStackParser::new().with_auto_ids(false);
571
572        let context = must_some(parser.parse_frame("main::(script.pl):42:", 77));
573        let eval = must_some(parser.parse_frame("(eval 10)[/path/to/file.pm:42]", 88));
574
575        assert_eq!(context.id, 77);
576        assert_eq!(eval.id, 88);
577    }
578
579    #[test]
580    fn test_parse_unrecognized() {
581        let mut parser = PerlStackParser::new();
582
583        let frame = parser.parse_frame("this is not a stack frame", 0);
584        assert!(frame.is_none());
585    }
586
587    #[test]
588    fn test_parse_unknown_frame_when_enabled() {
589        use perl_tdd_support::must_some;
590        let mut parser = PerlStackParser::new().with_unknown_frames(true);
591
592        let frame = must_some(parser.parse_frame("#2 DB::DB", 42));
593        assert_eq!(frame.name, "DB::DB");
594        assert_eq!(frame.line, 0);
595        assert!(frame.source.is_none());
596        assert_eq!(frame.id, 1);
597    }
598
599    #[test]
600    fn test_parse_unknown_frame_when_disabled() {
601        let mut parser = PerlStackParser::new();
602        assert!(parser.parse_frame("#2 DB::DB", 42).is_none());
603    }
604
605    #[test]
606    fn test_parse_stack_trace_includes_unknown_when_enabled() {
607        let mut parser = PerlStackParser::new().with_unknown_frames(true);
608        let output = r#"
609#0 DB::DB
610  #1  main::foo at script.pl line 10
611"#;
612
613        let frames = parser.parse_stack_trace(output);
614        assert_eq!(frames.len(), 2);
615        assert_eq!(frames[0].name, "DB::DB");
616        assert_eq!(frames[1].name, "main::foo");
617    }
618
619    #[test]
620    fn test_parse_standard_frame_with_space_in_file_path() {
621        use perl_tdd_support::must_some;
622        let mut parser = PerlStackParser::new();
623        let line = "  #0  main::foo at /tmp/My Project/script.pl line 10";
624        let frame = must_some(parser.parse_frame(line, 0));
625        assert_eq!(frame.name, "main::foo");
626        assert_eq!(frame.line, 10);
627        assert_eq!(frame.file_path(), Some("/tmp/My Project/script.pl"));
628    }
629}