1use super::{Source, StackFrame, StackFramePresentationHint};
7use once_cell::sync::Lazy;
8use regex::Regex;
9use thiserror::Error;
10
11#[derive(Debug, Error)]
13pub enum StackParseError {
14 #[error("unrecognized stack frame format: {0}")]
16 UnrecognizedFormat(String),
17
18 #[error("regex error: {0}")]
20 RegexError(#[from] regex::Error),
21}
22
23static 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
37static 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
47static 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
56static 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
65static 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
71static 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
77fn 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#[derive(Debug, Default)]
102pub struct PerlStackParser {
103 include_unknown_frames: bool,
105 auto_assign_ids: bool,
107 starting_id: i64,
109 next_id: i64,
111}
112
113impl PerlStackParser {
114 #[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 #[must_use]
122 pub fn with_unknown_frames(mut self, include: bool) -> Self {
123 self.include_unknown_frames = include;
124 self
125 }
126
127 #[must_use]
129 pub fn with_auto_ids(mut self, auto: bool) -> Self {
130 self.auto_assign_ids = auto;
131 self
132 }
133
134 #[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 pub fn parse_frame(&mut self, line: &str, id: i64) -> Option<StackFrame> {
153 let line = line.trim();
154
155 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 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 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 if let Some(caps) = context_re().and_then(|re| re.captures(line)) {
172 return self.build_frame_from_context(&caps, id);
173 }
174
175 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 fn build_frame_from_captures(
199 &mut self,
200 caps: ®ex::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 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 fn build_frame_from_context(
226 &mut self,
227 caps: ®ex::Captures<'_>,
228 provided_id: i64,
229 ) -> Option<StackFrame> {
230 let func = caps.name("func").map_or("main", |m| m.as_str());
232
233 let file = caps
235 .name("file")
236 .or_else(|| caps.name("file2_paren"))
237 .or_else(|| caps.name("file2"))?
238 .as_str();
239
240 if file.trim().is_empty() {
242 return None;
243 }
244
245 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 fn build_eval_frame(
259 &mut self,
260 caps: ®ex::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 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 pub fn parse_stack_trace(&mut self, output: &str) -> Vec<StackFrame> {
301 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 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 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 #[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 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 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 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 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 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}