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!(
69 f,
70 " at {name} ({}:{}:{})",
71 frame.file, frame.line, frame.column
72 )?;
73 }
74 Ok(())
75 }
76}
77
78#[derive(Debug, Clone)]
80pub struct ParsedStack {
81 pub message: Option<String>,
83 pub frames: Vec<StackFrame>,
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90enum Engine {
91 V8,
92 SpiderMonkey,
93 JavaScriptCore,
94}
95
96pub fn parse_stack_trace(input: &str) -> Vec<StackFrame> {
103 parse_stack_trace_full(input).frames
104}
105
106pub fn parse_stack_trace_full(input: &str) -> ParsedStack {
108 let mut lines = input.lines();
109 let mut message = None;
110 let mut frames = Vec::new();
111
112 let first_line = match lines.next() {
114 Some(l) => l,
115 None => {
116 return ParsedStack {
117 message: None,
118 frames: Vec::new(),
119 };
120 }
121 };
122
123 let engine = detect_engine(first_line);
124
125 if !is_frame_line(first_line, engine) {
127 message = Some(first_line.to_string());
128 } else if let Some(frame) = parse_frame(first_line, engine) {
129 frames.push(frame);
130 }
131
132 for line in lines {
133 if let Some(frame) = parse_frame(line, engine) {
134 frames.push(frame);
135 }
136 }
137
138 ParsedStack { message, frames }
139}
140
141fn detect_engine(first_line: &str) -> Engine {
143 let trimmed = first_line.trim();
144 if trimmed.starts_with(" at ") || trimmed.contains(" at ") {
145 Engine::V8
146 } else if trimmed.contains('@') && (trimmed.contains(':') || trimmed.contains('/')) {
147 Engine::SpiderMonkey
148 } else if trimmed.contains('@') {
149 Engine::JavaScriptCore
150 } else {
151 Engine::V8
153 }
154}
155
156fn is_frame_line(line: &str, engine: Engine) -> bool {
158 let trimmed = line.trim();
159 match engine {
160 Engine::V8 => trimmed.starts_with("at "),
161 Engine::SpiderMonkey | Engine::JavaScriptCore => trimmed.contains('@'),
162 }
163}
164
165fn parse_frame(line: &str, engine: Engine) -> Option<StackFrame> {
167 let trimmed = line.trim();
168
169 match engine {
170 Engine::V8 => parse_v8_frame(trimmed),
171 Engine::SpiderMonkey => parse_spidermonkey_frame(trimmed),
172 Engine::JavaScriptCore => parse_jsc_frame(trimmed),
173 }
174}
175
176fn parse_v8_frame(line: &str) -> Option<StackFrame> {
178 let rest = line.strip_prefix("at ")?;
179
180 if let Some(paren_start) = rest.rfind('(') {
182 let func = rest[..paren_start].trim();
183 let location = rest[paren_start + 1..].trim_end_matches(')').trim();
184 let (file, line_num, col) = parse_location(location)?;
185
186 return Some(StackFrame {
187 function_name: if func.is_empty() {
188 None
189 } else {
190 Some(func.to_string())
191 },
192 file,
193 line: line_num,
194 column: col,
195 });
196 }
197
198 let (file, line_num, col) = parse_location(rest)?;
200 Some(StackFrame {
201 function_name: None,
202 file,
203 line: line_num,
204 column: col,
205 })
206}
207
208fn parse_spidermonkey_frame(line: &str) -> Option<StackFrame> {
210 let (func, location) = line.split_once('@')?;
211 let (file, line_num, col) = parse_location(location)?;
212
213 Some(StackFrame {
214 function_name: if func.is_empty() {
215 None
216 } else {
217 Some(func.to_string())
218 },
219 file,
220 line: line_num,
221 column: col,
222 })
223}
224
225fn parse_jsc_frame(line: &str) -> Option<StackFrame> {
228 parse_spidermonkey_frame(line)
229}
230
231fn parse_location(location: &str) -> Option<(String, u32, u32)> {
234 let (rest, col_str) = location.rsplit_once(':')?;
236 let col: u32 = col_str.parse().ok()?;
237
238 let (file, line_str) = rest.rsplit_once(':')?;
239 let line_num: u32 = line_str.parse().ok()?;
240
241 if file.is_empty() {
242 return None;
243 }
244
245 Some((file.to_string(), line_num, col))
246}
247
248pub fn symbolicate<F>(stack: &str, loader: F) -> SymbolicatedStack
257where
258 F: Fn(&str) -> Option<SourceMap>,
259{
260 let parsed = parse_stack_trace_full(stack);
261 symbolicate_frames(&parsed.frames, parsed.message, &loader)
262}
263
264fn symbolicate_frames<F>(
266 frames: &[StackFrame],
267 message: Option<String>,
268 loader: &F,
269) -> SymbolicatedStack
270where
271 F: Fn(&str) -> Option<SourceMap>,
272{
273 let mut cache: HashMap<String, Option<SourceMap>> = HashMap::new();
274 let mut result_frames = Vec::with_capacity(frames.len());
275
276 for frame in frames {
277 let sm = cache
278 .entry(frame.file.clone())
279 .or_insert_with(|| loader(&frame.file));
280
281 let resolved = match sm {
282 Some(sm) => {
283 let line = frame.line.saturating_sub(1);
285 let column = frame.column.saturating_sub(1);
286
287 match sm.original_position_for(line, column) {
288 Some(loc) => SymbolicatedFrame {
289 function_name: loc
290 .name
291 .map(|n| sm.name(n).to_string())
292 .or_else(|| frame.function_name.clone()),
293 file: sm.source(loc.source).to_string(),
294 line: loc.line + 1, column: loc.column + 1, symbolicated: true,
297 },
298 None => SymbolicatedFrame {
299 function_name: frame.function_name.clone(),
300 file: frame.file.clone(),
301 line: frame.line,
302 column: frame.column,
303 symbolicated: false,
304 },
305 }
306 }
307 None => SymbolicatedFrame {
308 function_name: frame.function_name.clone(),
309 file: frame.file.clone(),
310 line: frame.line,
311 column: frame.column,
312 symbolicated: false,
313 },
314 };
315
316 result_frames.push(resolved);
317 }
318
319 SymbolicatedStack {
320 message,
321 frames: result_frames,
322 }
323}
324
325pub fn symbolicate_batch(
330 stacks: &[&str],
331 maps: &HashMap<String, SourceMap>,
332) -> Vec<SymbolicatedStack> {
333 stacks
334 .iter()
335 .map(|stack| symbolicate(stack, |file| maps.get(file).cloned()))
336 .collect()
337}
338
339pub fn resolve_by_debug_id<'a>(
344 debug_id: &str,
345 maps: &'a HashMap<String, SourceMap>,
346) -> Option<&'a SourceMap> {
347 maps.values()
348 .find(|sm| sm.debug_id.as_deref() == Some(debug_id))
349}
350
351pub fn to_json(stack: &SymbolicatedStack) -> String {
353 let frames: Vec<serde_json::Value> = stack
354 .frames
355 .iter()
356 .map(|f| {
357 serde_json::json!({
358 "functionName": f.function_name,
359 "file": f.file,
360 "line": f.line,
361 "column": f.column,
362 "symbolicated": f.symbolicated,
363 })
364 })
365 .collect();
366
367 let obj = serde_json::json!({
368 "message": stack.message,
369 "frames": frames,
370 });
371
372 serde_json::to_string_pretty(&obj).unwrap_or_default()
373}
374
375#[cfg(test)]
378mod tests {
379 use super::*;
380
381 #[test]
384 fn parse_v8_basic() {
385 let input = "Error: test\n at foo (bundle.js:10:5)\n at bar (bundle.js:20:10)";
386 let parsed = parse_stack_trace_full(input);
387 assert_eq!(parsed.message.as_deref(), Some("Error: test"));
388 assert_eq!(parsed.frames.len(), 2);
389 assert_eq!(parsed.frames[0].function_name.as_deref(), Some("foo"));
390 assert_eq!(parsed.frames[0].file, "bundle.js");
391 assert_eq!(parsed.frames[0].line, 10);
392 assert_eq!(parsed.frames[0].column, 5);
393 assert_eq!(parsed.frames[1].function_name.as_deref(), Some("bar"));
394 }
395
396 #[test]
397 fn parse_v8_anonymous() {
398 let input = "Error\n at bundle.js:10:5";
399 let frames = parse_stack_trace(input);
400 assert_eq!(frames.len(), 1);
401 assert!(frames[0].function_name.is_none());
402 assert_eq!(frames[0].file, "bundle.js");
403 }
404
405 #[test]
406 fn parse_v8_url() {
407 let input = "Error\n at foo (https://cdn.example.com/bundle.js:10:5)";
408 let frames = parse_stack_trace(input);
409 assert_eq!(frames[0].file, "https://cdn.example.com/bundle.js");
410 }
411
412 #[test]
415 fn parse_spidermonkey_basic() {
416 let input = "foo@bundle.js:10:5\nbar@bundle.js:20:10";
417 let frames = parse_stack_trace(input);
418 assert_eq!(frames.len(), 2);
419 assert_eq!(frames[0].function_name.as_deref(), Some("foo"));
420 assert_eq!(frames[0].file, "bundle.js");
421 assert_eq!(frames[0].line, 10);
422 }
423
424 #[test]
425 fn parse_spidermonkey_anonymous() {
426 let input = "@bundle.js:10:5";
427 let frames = parse_stack_trace(input);
428 assert_eq!(frames.len(), 1);
429 assert!(frames[0].function_name.is_none());
430 }
431
432 #[test]
433 fn parse_spidermonkey_url() {
434 let input = "foo@https://example.com/bundle.js:10:5";
435 let frames = parse_stack_trace(input);
436 assert_eq!(frames[0].file, "https://example.com/bundle.js");
437 }
438
439 #[test]
442 fn symbolicate_basic() {
443 let map_json = r#"{"version":3,"sources":["src/app.ts"],"names":["handleClick"],"mappings":"AAAA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AAAAA"}"#;
444
445 let stack = "Error: test\n at foo (bundle.js:10:1)";
446
447 let result = symbolicate(stack, |file| {
448 if file == "bundle.js" {
449 SourceMap::from_json(map_json).ok()
450 } else {
451 None
452 }
453 });
454
455 assert_eq!(result.message.as_deref(), Some("Error: test"));
456 assert_eq!(result.frames.len(), 1);
457 assert!(result.frames[0].symbolicated);
458 assert_eq!(result.frames[0].file, "src/app.ts");
459 assert_eq!(
460 result.frames[0].function_name.as_deref(),
461 Some("handleClick")
462 );
463 }
464
465 #[test]
466 fn symbolicate_no_map() {
467 let stack = "Error: test\n at foo (unknown.js:10:5)";
468 let result = symbolicate(stack, |_| None);
469 assert!(!result.frames[0].symbolicated);
470 assert_eq!(result.frames[0].file, "unknown.js");
471 }
472
473 #[test]
474 fn batch_symbolicate_test() {
475 let map_json = r#"{"version":3,"sources":["src/app.ts"],"names":[],"mappings":"AAAA"}"#;
476 let sm = SourceMap::from_json(map_json).unwrap();
477 let mut maps = HashMap::new();
478 maps.insert("bundle.js".to_string(), sm);
479
480 let stacks = vec![
481 "Error\n at foo (bundle.js:1:1)",
482 "Error\n at bar (bundle.js:1:1)",
483 ];
484 let results = symbolicate_batch(&stacks, &maps);
485 assert_eq!(results.len(), 2);
486 assert!(results[0].frames[0].symbolicated);
487 assert!(results[1].frames[0].symbolicated);
488 }
489
490 #[test]
491 fn debug_id_resolution() {
492 let map_json =
493 r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA","debugId":"abc-123"}"#;
494 let sm = SourceMap::from_json(map_json).unwrap();
495 let mut maps = HashMap::new();
496 maps.insert("bundle.js".to_string(), sm);
497
498 let found = resolve_by_debug_id("abc-123", &maps);
499 assert!(found.is_some());
500 assert_eq!(found.unwrap().debug_id.as_deref(), Some("abc-123"));
501
502 let not_found = resolve_by_debug_id("nonexistent", &maps);
503 assert!(not_found.is_none());
504 }
505
506 #[test]
507 fn to_json_output() {
508 let stack = SymbolicatedStack {
509 message: Some("Error: test".to_string()),
510 frames: vec![SymbolicatedFrame {
511 function_name: Some("foo".to_string()),
512 file: "src/app.ts".to_string(),
513 line: 42,
514 column: 10,
515 symbolicated: true,
516 }],
517 };
518 let json = to_json(&stack);
519 assert!(json.contains("Error: test"));
520 assert!(json.contains("src/app.ts"));
521 assert!(json.contains("\"symbolicated\": true"));
522 }
523
524 #[test]
525 fn display_format() {
526 let stack = SymbolicatedStack {
527 message: Some("Error: test".to_string()),
528 frames: vec![SymbolicatedFrame {
529 function_name: Some("foo".to_string()),
530 file: "app.ts".to_string(),
531 line: 42,
532 column: 10,
533 symbolicated: true,
534 }],
535 };
536 let output = format!("{stack}");
537 assert!(output.contains("Error: test"));
538 assert!(output.contains("at foo (app.ts:42:10)"));
539 }
540
541 #[test]
542 fn display_anonymous_frame() {
543 let stack = SymbolicatedStack {
544 message: None,
545 frames: vec![SymbolicatedFrame {
546 function_name: None,
547 file: "app.js".to_string(),
548 line: 1,
549 column: 1,
550 symbolicated: false,
551 }],
552 };
553 let output = format!("{stack}");
554 assert!(output.contains("<anonymous>"));
555 assert!(!output.contains("Error"));
556 }
557
558 #[test]
559 fn parse_empty_input() {
560 let parsed = parse_stack_trace_full("");
561 assert!(parsed.message.is_none());
562 assert!(parsed.frames.is_empty());
563 }
564
565 #[test]
566 fn parse_unparseable_lines() {
567 let input = "Error: boom\n this is not a frame\n neither is this";
569 let parsed = parse_stack_trace_full(input);
570 assert_eq!(parsed.message.as_deref(), Some("Error: boom"));
571 assert!(parsed.frames.is_empty());
572 }
573
574 #[test]
575 fn detect_jsc_engine() {
576 let input = "someFunc@native code";
578 let frames = parse_stack_trace(input);
579 assert!(frames.is_empty() || frames[0].function_name.as_deref() == Some("someFunc"));
581 }
582
583 #[test]
584 fn parse_v8_bare_location() {
585 let input = "Error\n at bundle.js:42:13";
587 let frames = parse_stack_trace(input);
588 assert_eq!(frames.len(), 1);
589 assert!(frames[0].function_name.is_none());
590 assert_eq!(frames[0].file, "bundle.js");
591 assert_eq!(frames[0].line, 42);
592 assert_eq!(frames[0].column, 13);
593 }
594
595 #[test]
596 fn parse_v8_empty_function_in_parens() {
597 let input = "Error\n at (bundle.js:10:5)";
599 let frames = parse_stack_trace(input);
600 assert_eq!(frames.len(), 1);
601 assert!(frames[0].function_name.is_none());
602 }
603
604 #[test]
605 fn parse_spidermonkey_anonymous_frame() {
606 let input = "@bundle.js:10:5\n@bundle.js:20:10";
608 let frames = parse_stack_trace(input);
609 assert_eq!(frames.len(), 2);
610 assert!(frames[0].function_name.is_none());
611 assert!(frames[1].function_name.is_none());
612 }
613
614 #[test]
615 fn parse_location_empty_file() {
616 let input = "Error\n at (:10:5)";
618 let frames = parse_stack_trace(input);
619 assert!(frames.is_empty());
620 }
621
622 #[test]
623 fn symbolicate_missing_map_for_some_files() {
624 let map_json = r#"{"version":3,"sources":["src/app.ts"],"names":[],"mappings":"AAAA"}"#;
625
626 let stack = "Error: test\n at foo (bundle.js:1:1)\n at bar (unknown.js:5:3)";
627 let result = symbolicate(stack, |file| {
628 if file == "bundle.js" {
629 SourceMap::from_json(map_json).ok()
630 } else {
631 None
632 }
633 });
634
635 assert_eq!(result.frames.len(), 2);
636 assert!(result.frames[0].symbolicated);
637 assert!(!result.frames[1].symbolicated);
638 assert_eq!(result.frames[1].file, "unknown.js");
639 assert_eq!(result.frames[1].function_name.as_deref(), Some("bar"));
640 }
641
642 #[test]
643 fn symbolicate_no_match_at_position() {
644 let map_json = r#"{"version":3,"sources":["src/app.ts"],"names":[],"mappings":"AAAA"}"#;
646
647 let stack = "Error: test\n at foo (bundle.js:100:100)";
648 let result = symbolicate(stack, |_| SourceMap::from_json(map_json).ok());
649
650 assert_eq!(result.frames.len(), 1);
651 assert!(!result.frames[0].file.is_empty());
654 }
655
656 #[test]
657 fn symbolicate_caches_source_maps() {
658 use std::cell::Cell;
659
660 let map_json = r#"{"version":3,"sources":["src/app.ts"],"names":[],"mappings":"AAAA"}"#;
662
663 let stack = "Error: test\n at foo (bundle.js:1:1)\n at bar (bundle.js:1:1)";
664 let call_count = Cell::new(0u32);
665 let result = symbolicate(stack, |file| {
666 call_count.set(call_count.get() + 1);
667 if file == "bundle.js" {
668 SourceMap::from_json(map_json).ok()
669 } else {
670 None
671 }
672 });
673
674 assert_eq!(result.frames.len(), 2);
675 assert!(result.frames[0].symbolicated);
677 assert!(result.frames[1].symbolicated);
678 }
679
680 #[test]
681 fn parse_default_engine_detection() {
682 let input = "TypeError: Cannot read property 'x' of null";
684 let parsed = parse_stack_trace_full(input);
685 assert_eq!(
686 parsed.message.as_deref(),
687 Some("TypeError: Cannot read property 'x' of null")
688 );
689 assert!(parsed.frames.is_empty());
690 }
691
692 #[test]
693 fn symbolicated_stack_display_with_message_and_mixed_frames() {
694 let stack = SymbolicatedStack {
695 message: Some("Error: oops".to_string()),
696 frames: vec![
697 SymbolicatedFrame {
698 function_name: Some("foo".to_string()),
699 file: "app.js".to_string(),
700 line: 10,
701 column: 5,
702 symbolicated: true,
703 },
704 SymbolicatedFrame {
705 function_name: None,
706 file: "lib.js".to_string(),
707 line: 20,
708 column: 1,
709 symbolicated: false,
710 },
711 ],
712 };
713 let output = stack.to_string();
714 assert!(output.contains("Error: oops"));
715 assert!(output.contains("foo"));
716 assert!(output.contains("<anonymous>"));
717 assert!(output.contains("app.js:10:5"));
718 assert!(output.contains("lib.js:20:1"));
719 }
720
721 #[test]
722 fn parse_v8_url_with_port() {
723 let input = "Error\n at foo (http://localhost:3000/bundle.js:42:13)";
724 let frames = parse_stack_trace(input);
725 assert_eq!(frames.len(), 1);
726 assert_eq!(frames[0].file, "http://localhost:3000/bundle.js");
727 assert_eq!(frames[0].line, 42);
728 assert_eq!(frames[0].column, 13);
729 }
730
731 #[test]
732 fn parse_v8_bare_url_with_port() {
733 let input = "Error\n at http://localhost:3000/bundle.js:10:5";
735 let frames = parse_stack_trace(input);
736 assert_eq!(frames.len(), 1);
737 assert!(frames[0].function_name.is_none());
738 assert_eq!(frames[0].file, "http://localhost:3000/bundle.js");
739 assert_eq!(frames[0].line, 10);
740 assert_eq!(frames[0].column, 5);
741 }
742
743 #[test]
744 fn parse_spidermonkey_with_message_line() {
745 let input = "foo@http://example.com/bundle.js:10:5\nbar@http://example.com/bundle.js:20:10";
748 let parsed = parse_stack_trace_full(input);
749 assert!(parsed.message.is_none());
750 assert_eq!(parsed.frames.len(), 2);
751 assert_eq!(parsed.frames[0].function_name.as_deref(), Some("foo"));
752 assert_eq!(parsed.frames[0].file, "http://example.com/bundle.js");
753 assert_eq!(parsed.frames[0].line, 10);
754 assert_eq!(parsed.frames[1].function_name.as_deref(), Some("bar"));
755 assert_eq!(parsed.frames[1].line, 20);
756 }
757
758 #[test]
759 fn parse_spidermonkey_url_with_port() {
760 let input = "handler@http://localhost:8080/app.js:42:13";
761 let frames = parse_stack_trace(input);
762 assert_eq!(frames.len(), 1);
763 assert_eq!(frames[0].function_name.as_deref(), Some("handler"));
764 assert_eq!(frames[0].file, "http://localhost:8080/app.js");
765 assert_eq!(frames[0].line, 42);
766 assert_eq!(frames[0].column, 13);
767 }
768
769 #[test]
770 fn detect_v8_engine_from_frame_line() {
771 let engine = detect_engine(" at foo (bundle.js:1:1)");
773 assert_eq!(engine, Engine::V8);
774 }
775
776 #[test]
777 fn detect_jsc_engine_at_sign_only() {
778 let engine = detect_engine("func@native");
780 assert_eq!(engine, Engine::JavaScriptCore);
781 }
782
783 #[test]
784 fn parse_location_returns_none_for_invalid_column() {
785 let result = parse_location("file.js:10:abc");
787 assert!(result.is_none());
788 }
789
790 #[test]
791 fn parse_location_returns_none_for_invalid_line() {
792 let result = parse_location("file.js:abc:5");
794 assert!(result.is_none());
795 }
796
797 #[test]
798 fn parse_location_simple() {
799 let result = parse_location("bundle.js:42:13");
800 assert!(result.is_some());
801 let (file, line, col) = result.unwrap();
802 assert_eq!(file, "bundle.js");
803 assert_eq!(line, 42);
804 assert_eq!(col, 13);
805 }
806
807 #[test]
808 fn parse_location_url_with_port() {
809 let result = parse_location("http://localhost:3000/bundle.js:42:13");
810 assert!(result.is_some());
811 let (file, line, col) = result.unwrap();
812 assert_eq!(file, "http://localhost:3000/bundle.js");
813 assert_eq!(line, 42);
814 assert_eq!(col, 13);
815 }
816}