Skip to main content

srcmap_symbolicate/
lib.rs

1//! Stack trace symbolication using source maps.
2//!
3//! Parses stack traces from V8, SpiderMonkey, and JavaScriptCore,
4//! resolves each frame through source maps, and produces readable output.
5//!
6//! # Examples
7//!
8//! ```
9//! use srcmap_symbolicate::{parse_stack_trace, symbolicate, StackFrame};
10//!
11//! let stack = "Error: oops\n    at foo (bundle.js:10:5)\n    at bar (bundle.js:20:10)";
12//! let frames = parse_stack_trace(stack);
13//! assert_eq!(frames.len(), 2);
14//! assert_eq!(frames[0].function_name.as_deref(), Some("foo"));
15//! ```
16
17use std::collections::HashMap;
18use std::fmt;
19
20use srcmap_sourcemap::SourceMap;
21
22// ── Types ───────────────────────────────────────────────────────
23
24/// A single parsed stack frame.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct StackFrame {
27    /// Function name (if available).
28    pub function_name: Option<String>,
29    /// Source file path or URL.
30    pub file: String,
31    /// Line number (1-based as in stack traces).
32    pub line: u32,
33    /// Column number (1-based as in stack traces).
34    pub column: u32,
35}
36
37/// A symbolicated (resolved) stack frame.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct SymbolicatedFrame {
40    /// Original function name from source map (or original function name).
41    pub function_name: Option<String>,
42    /// Resolved original source file.
43    pub file: String,
44    /// Resolved original line (1-based).
45    pub line: u32,
46    /// Resolved original column (1-based).
47    pub column: u32,
48    /// Whether this frame was successfully symbolicated.
49    pub symbolicated: bool,
50}
51
52/// A full symbolicated stack trace.
53#[derive(Debug, Clone)]
54pub struct SymbolicatedStack {
55    /// Error message (first line of the stack trace).
56    pub message: Option<String>,
57    /// Resolved frames.
58    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/// Result of parsing a stack trace: the message line and the parsed frames.
79#[derive(Debug, Clone)]
80pub struct ParsedStack {
81    /// Error message (e.g. "Error: something went wrong").
82    pub message: Option<String>,
83    /// Parsed stack frames.
84    pub frames: Vec<StackFrame>,
85}
86
87// ── Stack trace engine detection ─────────────────────────────────
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90enum Engine {
91    V8,
92    SpiderMonkey,
93    JavaScriptCore,
94}
95
96// ── Parser ──────────────────────────────────────────────────────
97
98/// Parse a stack trace string into individual frames.
99///
100/// Supports V8 (Chrome, Node.js), SpiderMonkey (Firefox), and
101/// JavaScriptCore (Safari) stack trace formats.
102pub fn parse_stack_trace(input: &str) -> Vec<StackFrame> {
103    parse_stack_trace_full(input).frames
104}
105
106/// Parse a stack trace string into message + frames.
107pub 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    // Detect engine and extract message from first line
113    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 the first line looks like a message (not a frame), save it
126    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
141/// Detect the JavaScript engine from the first line of a stack trace.
142fn 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        // Default to V8 for error message lines
152        Engine::V8
153    }
154}
155
156/// Check if a line looks like a stack frame (vs an error message).
157fn 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
165/// Parse a single stack frame line.
166fn 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
176/// Parse a V8 stack frame: `at functionName (file:line:column)` or `at file:line:column`
177fn parse_v8_frame(line: &str) -> Option<StackFrame> {
178    let rest = line.strip_prefix("at ")?;
179
180    // Check for `functionName (file:line:column)` format
181    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    // Bare `file:line:column` format
199    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
208/// Parse a SpiderMonkey stack frame: `functionName@file:line:column`
209fn 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
225/// Parse a JavaScriptCore stack frame: `functionName@file:line:column`
226/// Same format as SpiderMonkey.
227fn parse_jsc_frame(line: &str) -> Option<StackFrame> {
228    parse_spidermonkey_frame(line)
229}
230
231/// Parse a location string: `file:line:column` or `file:line`
232/// Handles URLs with colons (http://host:port/file:line:column)
233fn parse_location(location: &str) -> Option<(String, u32, u32)> {
234    // Split from the right to handle URLs with colons
235    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
248// ── Symbolication ───────────────────────────────────────────────
249
250/// Symbolicate a stack trace using a source map loader function.
251///
252/// The `loader` is called with each unique source file and should return
253/// the corresponding `SourceMap`, or `None` if not available.
254///
255/// Stack trace lines/columns are 1-based; source maps use 0-based internally.
256pub 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
264/// Symbolicate pre-parsed frames.
265fn 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                // Stack traces are 1-based, source maps are 0-based
284                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,     // back to 1-based
295                        column: loc.column + 1, // back to 1-based
296                        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
325/// Batch symbolicate multiple stack traces against pre-loaded source maps.
326///
327/// `maps` is a map of source file → SourceMap. All stack traces are resolved
328/// against these pre-loaded maps without additional loading.
329pub 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
339/// Resolve a debug ID to a source map from a set of maps indexed by debug ID.
340///
341/// Useful for error monitoring systems where source maps are identified by
342/// their debug ID rather than by filename.
343pub 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
351/// Serialize a symbolicated stack to JSON.
352pub 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// ── Tests ───────────────────────────────────────────────────────
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380
381    // ── V8 format tests ──────────────────────────────────────────
382
383    #[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    // ── SpiderMonkey format tests ────────────────────────────────
413
414    #[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    // ── Symbolication tests ──────────────────────────────────────
440
441    #[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        // Lines that don't match any frame format
568        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        // JSC: has @ but no : or / (just @ sign alone)
577        let input = "someFunc@native code";
578        let frames = parse_stack_trace(input);
579        // Should detect as JSC and try to parse
580        assert!(frames.is_empty() || frames[0].function_name.as_deref() == Some("someFunc"));
581    }
582
583    #[test]
584    fn parse_v8_bare_location() {
585        // V8 bare format: `at file:line:column` (no parens, no function name)
586        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        // V8 with empty function name before parens
598        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        // SpiderMonkey: @file:line:col with empty function name
607        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        // parse_location returns None when file component is empty
617        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        // Source map exists but no mapping at the requested position
645        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        // Position 99:99 (0-based) is beyond any mapping, frame should not be symbolicated
652        // Actually it may snap to closest - let's check either way
653        assert!(!result.frames[0].file.is_empty());
654    }
655
656    #[test]
657    fn symbolicate_caches_source_maps() {
658        use std::cell::Cell;
659
660        // Multiple frames from the same file should only call the loader once
661        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        // Both should be resolved
676        assert!(result.frames[0].symbolicated);
677        assert!(result.frames[1].symbolicated);
678    }
679
680    #[test]
681    fn parse_default_engine_detection() {
682        // First line is just an error message, no frame indicators
683        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        // V8 bare format with a URL containing a port
734        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        // SpiderMonkey stack where the second line (after engine detection from first
746        // frame line) goes through parse_spidermonkey_frame
747        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        // First line is itself a V8 frame (contains "    at ")
772        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        // JSC: has @ but no colon or slash
779        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        // If column is not a number, parse_location should return None
786        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        // If line is not a number, parse_location should return None
793        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}