1use std::collections::HashMap;
18use std::fmt;
19
20use srcmap_sourcemap::SourceMap;
21
22#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct StackFrame {
27 pub function_name: Option<String>,
29 pub file: String,
31 pub line: u32,
33 pub column: u32,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct SymbolicatedFrame {
40 pub function_name: Option<String>,
42 pub file: String,
44 pub line: u32,
46 pub column: u32,
48 pub symbolicated: bool,
50}
51
52#[derive(Debug, Clone)]
54pub struct SymbolicatedStack {
55 pub message: Option<String>,
57 pub frames: Vec<SymbolicatedFrame>,
59}
60
61impl fmt::Display for SymbolicatedStack {
62 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63 if let Some(ref msg) = self.message {
64 writeln!(f, "{msg}")?;
65 }
66 for frame in &self.frames {
67 let name = frame.function_name.as_deref().unwrap_or("<anonymous>");
68 writeln!(f, " at {name} ({}:{}:{})", frame.file, frame.line, frame.column)?;
69 }
70 Ok(())
71 }
72}
73
74#[derive(Debug, Clone)]
76pub struct ParsedStack {
77 pub message: Option<String>,
79 pub frames: Vec<StackFrame>,
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86enum Engine {
87 V8,
88 SpiderMonkey,
89 JavaScriptCore,
90}
91
92pub fn parse_stack_trace(input: &str) -> Vec<StackFrame> {
99 parse_stack_trace_full(input).frames
100}
101
102pub fn parse_stack_trace_full(input: &str) -> ParsedStack {
104 let mut lines = input.lines();
105 let mut message = None;
106 let mut frames = Vec::new();
107
108 let first_line = match lines.next() {
110 Some(l) => l,
111 None => {
112 return ParsedStack { message: None, frames: Vec::new() };
113 }
114 };
115
116 let engine = detect_engine(first_line);
117
118 if !is_frame_line(first_line, engine) {
120 message = Some(first_line.to_string());
121 } else if let Some(frame) = parse_frame(first_line, engine) {
122 frames.push(frame);
123 }
124
125 for line in lines {
126 if let Some(frame) = parse_frame(line, engine) {
127 frames.push(frame);
128 }
129 }
130
131 ParsedStack { message, frames }
132}
133
134fn detect_engine(first_line: &str) -> Engine {
136 let trimmed = first_line.trim();
137 if trimmed.starts_with(" at ") || trimmed.contains(" at ") {
138 Engine::V8
139 } else if trimmed.contains('@') && (trimmed.contains(':') || trimmed.contains('/')) {
140 Engine::SpiderMonkey
141 } else if trimmed.contains('@') {
142 Engine::JavaScriptCore
143 } else {
144 Engine::V8
146 }
147}
148
149fn is_frame_line(line: &str, engine: Engine) -> bool {
151 let trimmed = line.trim();
152 match engine {
153 Engine::V8 => trimmed.starts_with("at "),
154 Engine::SpiderMonkey | Engine::JavaScriptCore => trimmed.contains('@'),
155 }
156}
157
158fn parse_frame(line: &str, engine: Engine) -> Option<StackFrame> {
160 let trimmed = line.trim();
161
162 match engine {
163 Engine::V8 => parse_v8_frame(trimmed),
164 Engine::SpiderMonkey => parse_spidermonkey_frame(trimmed),
165 Engine::JavaScriptCore => parse_jsc_frame(trimmed),
166 }
167}
168
169fn parse_v8_frame(line: &str) -> Option<StackFrame> {
171 let rest = line.strip_prefix("at ")?;
172
173 if let Some(paren_start) = rest.rfind('(') {
175 let func = rest[..paren_start].trim();
176 let location = rest[paren_start + 1..].trim_end_matches(')').trim();
177 let (file, line_num, col) = parse_location(location)?;
178
179 return Some(StackFrame {
180 function_name: if func.is_empty() { None } else { Some(func.to_string()) },
181 file,
182 line: line_num,
183 column: col,
184 });
185 }
186
187 let (file, line_num, col) = parse_location(rest)?;
189 Some(StackFrame { function_name: None, file, line: line_num, column: col })
190}
191
192fn parse_spidermonkey_frame(line: &str) -> Option<StackFrame> {
194 let (func, location) = line.split_once('@')?;
195 let (file, line_num, col) = parse_location(location)?;
196
197 Some(StackFrame {
198 function_name: if func.is_empty() { None } else { Some(func.to_string()) },
199 file,
200 line: line_num,
201 column: col,
202 })
203}
204
205fn parse_jsc_frame(line: &str) -> Option<StackFrame> {
208 parse_spidermonkey_frame(line)
209}
210
211fn parse_location(location: &str) -> Option<(String, u32, u32)> {
214 let (rest, col_str) = location.rsplit_once(':')?;
216 let col: u32 = col_str.parse().ok()?;
217
218 let (file, line_str) = rest.rsplit_once(':')?;
219 let line_num: u32 = line_str.parse().ok()?;
220
221 if file.is_empty() {
222 return None;
223 }
224
225 Some((file.to_string(), line_num, col))
226}
227
228pub fn symbolicate<F>(stack: &str, loader: F) -> SymbolicatedStack
237where
238 F: Fn(&str) -> Option<SourceMap>,
239{
240 let parsed = parse_stack_trace_full(stack);
241 symbolicate_frames(&parsed.frames, parsed.message, &loader)
242}
243
244fn symbolicate_frames<F>(
246 frames: &[StackFrame],
247 message: Option<String>,
248 loader: &F,
249) -> SymbolicatedStack
250where
251 F: Fn(&str) -> Option<SourceMap>,
252{
253 let mut cache: HashMap<String, Option<SourceMap>> = HashMap::new();
254 let mut result_frames = Vec::with_capacity(frames.len());
255
256 for frame in frames {
257 let sm = cache.entry(frame.file.clone()).or_insert_with(|| loader(&frame.file));
258
259 let resolved = match sm {
260 Some(sm) => {
261 let line = frame.line.saturating_sub(1);
263 let column = frame.column.saturating_sub(1);
264
265 match sm.original_position_for(line, column) {
266 Some(loc) => SymbolicatedFrame {
267 function_name: loc
268 .name
269 .map(|n| sm.name(n).to_string())
270 .or_else(|| frame.function_name.clone()),
271 file: sm.source(loc.source).to_string(),
272 line: loc.line + 1, column: loc.column + 1, symbolicated: true,
275 },
276 None => SymbolicatedFrame {
277 function_name: frame.function_name.clone(),
278 file: frame.file.clone(),
279 line: frame.line,
280 column: frame.column,
281 symbolicated: false,
282 },
283 }
284 }
285 None => SymbolicatedFrame {
286 function_name: frame.function_name.clone(),
287 file: frame.file.clone(),
288 line: frame.line,
289 column: frame.column,
290 symbolicated: false,
291 },
292 };
293
294 result_frames.push(resolved);
295 }
296
297 SymbolicatedStack { message, frames: result_frames }
298}
299
300pub fn symbolicate_batch(
305 stacks: &[&str],
306 maps: &HashMap<String, SourceMap>,
307) -> Vec<SymbolicatedStack> {
308 stacks.iter().map(|stack| symbolicate(stack, |file| maps.get(file).cloned())).collect()
309}
310
311pub fn resolve_by_debug_id<'a>(
316 debug_id: &str,
317 maps: &'a HashMap<String, SourceMap>,
318) -> Option<&'a SourceMap> {
319 maps.values().find(|sm| sm.debug_id.as_deref() == Some(debug_id))
320}
321
322pub fn to_json(stack: &SymbolicatedStack) -> String {
324 let frames: Vec<serde_json::Value> = stack
325 .frames
326 .iter()
327 .map(|f| {
328 serde_json::json!({
329 "functionName": f.function_name,
330 "file": f.file,
331 "line": f.line,
332 "column": f.column,
333 "symbolicated": f.symbolicated,
334 })
335 })
336 .collect();
337
338 let obj = serde_json::json!({
339 "message": stack.message,
340 "frames": frames,
341 });
342
343 serde_json::to_string_pretty(&obj).unwrap_or_default()
344}
345
346#[cfg(test)]
349mod tests {
350 use super::*;
351
352 #[test]
355 fn parse_v8_basic() {
356 let input = "Error: test\n at foo (bundle.js:10:5)\n at bar (bundle.js:20:10)";
357 let parsed = parse_stack_trace_full(input);
358 assert_eq!(parsed.message.as_deref(), Some("Error: test"));
359 assert_eq!(parsed.frames.len(), 2);
360 assert_eq!(parsed.frames[0].function_name.as_deref(), Some("foo"));
361 assert_eq!(parsed.frames[0].file, "bundle.js");
362 assert_eq!(parsed.frames[0].line, 10);
363 assert_eq!(parsed.frames[0].column, 5);
364 assert_eq!(parsed.frames[1].function_name.as_deref(), Some("bar"));
365 }
366
367 #[test]
368 fn parse_v8_anonymous() {
369 let input = "Error\n at bundle.js:10:5";
370 let frames = parse_stack_trace(input);
371 assert_eq!(frames.len(), 1);
372 assert!(frames[0].function_name.is_none());
373 assert_eq!(frames[0].file, "bundle.js");
374 }
375
376 #[test]
377 fn parse_v8_url() {
378 let input = "Error\n at foo (https://cdn.example.com/bundle.js:10:5)";
379 let frames = parse_stack_trace(input);
380 assert_eq!(frames[0].file, "https://cdn.example.com/bundle.js");
381 }
382
383 #[test]
386 fn parse_spidermonkey_basic() {
387 let input = "foo@bundle.js:10:5\nbar@bundle.js:20:10";
388 let frames = parse_stack_trace(input);
389 assert_eq!(frames.len(), 2);
390 assert_eq!(frames[0].function_name.as_deref(), Some("foo"));
391 assert_eq!(frames[0].file, "bundle.js");
392 assert_eq!(frames[0].line, 10);
393 }
394
395 #[test]
396 fn parse_spidermonkey_anonymous() {
397 let input = "@bundle.js:10:5";
398 let frames = parse_stack_trace(input);
399 assert_eq!(frames.len(), 1);
400 assert!(frames[0].function_name.is_none());
401 }
402
403 #[test]
404 fn parse_spidermonkey_url() {
405 let input = "foo@https://example.com/bundle.js:10:5";
406 let frames = parse_stack_trace(input);
407 assert_eq!(frames[0].file, "https://example.com/bundle.js");
408 }
409
410 #[test]
413 fn symbolicate_basic() {
414 let map_json = r#"{"version":3,"sources":["src/app.ts"],"names":["handleClick"],"mappings":"AAAA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AAAAA"}"#;
415
416 let stack = "Error: test\n at foo (bundle.js:10:1)";
417
418 let result = symbolicate(stack, |file| {
419 if file == "bundle.js" { SourceMap::from_json(map_json).ok() } else { None }
420 });
421
422 assert_eq!(result.message.as_deref(), Some("Error: test"));
423 assert_eq!(result.frames.len(), 1);
424 assert!(result.frames[0].symbolicated);
425 assert_eq!(result.frames[0].file, "src/app.ts");
426 assert_eq!(result.frames[0].function_name.as_deref(), Some("handleClick"));
427 }
428
429 #[test]
430 fn symbolicate_no_map() {
431 let stack = "Error: test\n at foo (unknown.js:10:5)";
432 let result = symbolicate(stack, |_| None);
433 assert!(!result.frames[0].symbolicated);
434 assert_eq!(result.frames[0].file, "unknown.js");
435 }
436
437 #[test]
438 fn batch_symbolicate_test() {
439 let map_json = r#"{"version":3,"sources":["src/app.ts"],"names":[],"mappings":"AAAA"}"#;
440 let sm = SourceMap::from_json(map_json).unwrap();
441 let mut maps = HashMap::new();
442 maps.insert("bundle.js".to_string(), sm);
443
444 let stacks = vec!["Error\n at foo (bundle.js:1:1)", "Error\n at bar (bundle.js:1:1)"];
445 let results = symbolicate_batch(&stacks, &maps);
446 assert_eq!(results.len(), 2);
447 assert!(results[0].frames[0].symbolicated);
448 assert!(results[1].frames[0].symbolicated);
449 }
450
451 #[test]
452 fn debug_id_resolution() {
453 let map_json =
454 r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA","debugId":"abc-123"}"#;
455 let sm = SourceMap::from_json(map_json).unwrap();
456 let mut maps = HashMap::new();
457 maps.insert("bundle.js".to_string(), sm);
458
459 let found = resolve_by_debug_id("abc-123", &maps);
460 assert!(found.is_some());
461 assert_eq!(found.unwrap().debug_id.as_deref(), Some("abc-123"));
462
463 let not_found = resolve_by_debug_id("nonexistent", &maps);
464 assert!(not_found.is_none());
465 }
466
467 #[test]
468 fn to_json_output() {
469 let stack = SymbolicatedStack {
470 message: Some("Error: test".to_string()),
471 frames: vec![SymbolicatedFrame {
472 function_name: Some("foo".to_string()),
473 file: "src/app.ts".to_string(),
474 line: 42,
475 column: 10,
476 symbolicated: true,
477 }],
478 };
479 let json = to_json(&stack);
480 assert!(json.contains("Error: test"));
481 assert!(json.contains("src/app.ts"));
482 assert!(json.contains("\"symbolicated\": true"));
483 }
484
485 #[test]
486 fn display_format() {
487 let stack = SymbolicatedStack {
488 message: Some("Error: test".to_string()),
489 frames: vec![SymbolicatedFrame {
490 function_name: Some("foo".to_string()),
491 file: "app.ts".to_string(),
492 line: 42,
493 column: 10,
494 symbolicated: true,
495 }],
496 };
497 let output = format!("{stack}");
498 assert!(output.contains("Error: test"));
499 assert!(output.contains("at foo (app.ts:42:10)"));
500 }
501
502 #[test]
503 fn display_anonymous_frame() {
504 let stack = SymbolicatedStack {
505 message: None,
506 frames: vec![SymbolicatedFrame {
507 function_name: None,
508 file: "app.js".to_string(),
509 line: 1,
510 column: 1,
511 symbolicated: false,
512 }],
513 };
514 let output = format!("{stack}");
515 assert!(output.contains("<anonymous>"));
516 assert!(!output.contains("Error"));
517 }
518
519 #[test]
520 fn parse_empty_input() {
521 let parsed = parse_stack_trace_full("");
522 assert!(parsed.message.is_none());
523 assert!(parsed.frames.is_empty());
524 }
525
526 #[test]
527 fn parse_unparsable_lines() {
528 let input = "Error: boom\n this is not a frame\n neither is this";
530 let parsed = parse_stack_trace_full(input);
531 assert_eq!(parsed.message.as_deref(), Some("Error: boom"));
532 assert!(parsed.frames.is_empty());
533 }
534
535 #[test]
536 fn detect_jsc_engine() {
537 let input = "someFunc@native code";
539 let frames = parse_stack_trace(input);
540 assert!(frames.is_empty() || frames[0].function_name.as_deref() == Some("someFunc"));
542 }
543
544 #[test]
545 fn parse_v8_bare_location() {
546 let input = "Error\n at bundle.js:42:13";
548 let frames = parse_stack_trace(input);
549 assert_eq!(frames.len(), 1);
550 assert!(frames[0].function_name.is_none());
551 assert_eq!(frames[0].file, "bundle.js");
552 assert_eq!(frames[0].line, 42);
553 assert_eq!(frames[0].column, 13);
554 }
555
556 #[test]
557 fn parse_v8_empty_function_in_parens() {
558 let input = "Error\n at (bundle.js:10:5)";
560 let frames = parse_stack_trace(input);
561 assert_eq!(frames.len(), 1);
562 assert!(frames[0].function_name.is_none());
563 }
564
565 #[test]
566 fn parse_spidermonkey_anonymous_frame() {
567 let input = "@bundle.js:10:5\n@bundle.js:20:10";
569 let frames = parse_stack_trace(input);
570 assert_eq!(frames.len(), 2);
571 assert!(frames[0].function_name.is_none());
572 assert!(frames[1].function_name.is_none());
573 }
574
575 #[test]
576 fn parse_location_empty_file() {
577 let input = "Error\n at (:10:5)";
579 let frames = parse_stack_trace(input);
580 assert!(frames.is_empty());
581 }
582
583 #[test]
584 fn symbolicate_missing_map_for_some_files() {
585 let map_json = r#"{"version":3,"sources":["src/app.ts"],"names":[],"mappings":"AAAA"}"#;
586
587 let stack = "Error: test\n at foo (bundle.js:1:1)\n at bar (unknown.js:5:3)";
588 let result = symbolicate(stack, |file| {
589 if file == "bundle.js" { SourceMap::from_json(map_json).ok() } else { None }
590 });
591
592 assert_eq!(result.frames.len(), 2);
593 assert!(result.frames[0].symbolicated);
594 assert!(!result.frames[1].symbolicated);
595 assert_eq!(result.frames[1].file, "unknown.js");
596 assert_eq!(result.frames[1].function_name.as_deref(), Some("bar"));
597 }
598
599 #[test]
600 fn symbolicate_no_match_at_position() {
601 let map_json = r#"{"version":3,"sources":["src/app.ts"],"names":[],"mappings":"AAAA"}"#;
603
604 let stack = "Error: test\n at foo (bundle.js:100:100)";
605 let result = symbolicate(stack, |_| SourceMap::from_json(map_json).ok());
606
607 assert_eq!(result.frames.len(), 1);
608 assert!(!result.frames[0].file.is_empty());
611 }
612
613 #[test]
614 fn symbolicate_caches_source_maps() {
615 use std::cell::Cell;
616
617 let map_json = r#"{"version":3,"sources":["src/app.ts"],"names":[],"mappings":"AAAA"}"#;
619
620 let stack = "Error: test\n at foo (bundle.js:1:1)\n at bar (bundle.js:1:1)";
621 let call_count = Cell::new(0u32);
622 let result = symbolicate(stack, |file| {
623 call_count.set(call_count.get() + 1);
624 if file == "bundle.js" { SourceMap::from_json(map_json).ok() } else { None }
625 });
626
627 assert_eq!(result.frames.len(), 2);
628 assert!(result.frames[0].symbolicated);
630 assert!(result.frames[1].symbolicated);
631 }
632
633 #[test]
634 fn parse_default_engine_detection() {
635 let input = "TypeError: Cannot read property 'x' of null";
637 let parsed = parse_stack_trace_full(input);
638 assert_eq!(parsed.message.as_deref(), Some("TypeError: Cannot read property 'x' of null"));
639 assert!(parsed.frames.is_empty());
640 }
641
642 #[test]
643 fn symbolicated_stack_display_with_message_and_mixed_frames() {
644 let stack = SymbolicatedStack {
645 message: Some("Error: oops".to_string()),
646 frames: vec![
647 SymbolicatedFrame {
648 function_name: Some("foo".to_string()),
649 file: "app.js".to_string(),
650 line: 10,
651 column: 5,
652 symbolicated: true,
653 },
654 SymbolicatedFrame {
655 function_name: None,
656 file: "lib.js".to_string(),
657 line: 20,
658 column: 1,
659 symbolicated: false,
660 },
661 ],
662 };
663 let output = stack.to_string();
664 assert!(output.contains("Error: oops"));
665 assert!(output.contains("foo"));
666 assert!(output.contains("<anonymous>"));
667 assert!(output.contains("app.js:10:5"));
668 assert!(output.contains("lib.js:20:1"));
669 }
670
671 #[test]
672 fn parse_v8_url_with_port() {
673 let input = "Error\n at foo (http://localhost:3000/bundle.js:42:13)";
674 let frames = parse_stack_trace(input);
675 assert_eq!(frames.len(), 1);
676 assert_eq!(frames[0].file, "http://localhost:3000/bundle.js");
677 assert_eq!(frames[0].line, 42);
678 assert_eq!(frames[0].column, 13);
679 }
680
681 #[test]
682 fn parse_v8_bare_url_with_port() {
683 let input = "Error\n at http://localhost:3000/bundle.js:10:5";
685 let frames = parse_stack_trace(input);
686 assert_eq!(frames.len(), 1);
687 assert!(frames[0].function_name.is_none());
688 assert_eq!(frames[0].file, "http://localhost:3000/bundle.js");
689 assert_eq!(frames[0].line, 10);
690 assert_eq!(frames[0].column, 5);
691 }
692
693 #[test]
694 fn parse_spidermonkey_with_message_line() {
695 let input = "foo@http://example.com/bundle.js:10:5\nbar@http://example.com/bundle.js:20:10";
698 let parsed = parse_stack_trace_full(input);
699 assert!(parsed.message.is_none());
700 assert_eq!(parsed.frames.len(), 2);
701 assert_eq!(parsed.frames[0].function_name.as_deref(), Some("foo"));
702 assert_eq!(parsed.frames[0].file, "http://example.com/bundle.js");
703 assert_eq!(parsed.frames[0].line, 10);
704 assert_eq!(parsed.frames[1].function_name.as_deref(), Some("bar"));
705 assert_eq!(parsed.frames[1].line, 20);
706 }
707
708 #[test]
709 fn parse_spidermonkey_url_with_port() {
710 let input = "handler@http://localhost:8080/app.js:42:13";
711 let frames = parse_stack_trace(input);
712 assert_eq!(frames.len(), 1);
713 assert_eq!(frames[0].function_name.as_deref(), Some("handler"));
714 assert_eq!(frames[0].file, "http://localhost:8080/app.js");
715 assert_eq!(frames[0].line, 42);
716 assert_eq!(frames[0].column, 13);
717 }
718
719 #[test]
720 fn detect_v8_engine_from_frame_line() {
721 let engine = detect_engine(" at foo (bundle.js:1:1)");
723 assert_eq!(engine, Engine::V8);
724 }
725
726 #[test]
727 fn detect_jsc_engine_at_sign_only() {
728 let engine = detect_engine("func@native");
730 assert_eq!(engine, Engine::JavaScriptCore);
731 }
732
733 #[test]
734 fn parse_location_returns_none_for_invalid_column() {
735 let result = parse_location("file.js:10:abc");
737 assert!(result.is_none());
738 }
739
740 #[test]
741 fn parse_location_returns_none_for_invalid_line() {
742 let result = parse_location("file.js:abc:5");
744 assert!(result.is_none());
745 }
746
747 #[test]
748 fn parse_location_simple() {
749 let result = parse_location("bundle.js:42:13");
750 assert!(result.is_some());
751 let (file, line, col) = result.unwrap();
752 assert_eq!(file, "bundle.js");
753 assert_eq!(line, 42);
754 assert_eq!(col, 13);
755 }
756
757 #[test]
758 fn parse_location_url_with_port() {
759 let result = parse_location("http://localhost:3000/bundle.js:42:13");
760 assert!(result.is_some());
761 let (file, line, col) = result.unwrap();
762 assert_eq!(file, "http://localhost:3000/bundle.js");
763 assert_eq!(line, 42);
764 assert_eq!(col, 13);
765 }
766}