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!(f, "    at {name} ({}:{}:{})", frame.file, frame.line, frame.column)?;
69        }
70        Ok(())
71    }
72}
73
74/// Result of parsing a stack trace: the message line and the parsed frames.
75#[derive(Debug, Clone)]
76pub struct ParsedStack {
77    /// Error message (e.g. "Error: something went wrong").
78    pub message: Option<String>,
79    /// Parsed stack frames.
80    pub frames: Vec<StackFrame>,
81}
82
83// ── Stack trace engine detection ─────────────────────────────────
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86enum Engine {
87    V8,
88    SpiderMonkey,
89    JavaScriptCore,
90}
91
92// ── Parser ──────────────────────────────────────────────────────
93
94/// Parse a stack trace string into individual frames.
95///
96/// Supports V8 (Chrome, Node.js), SpiderMonkey (Firefox), and
97/// JavaScriptCore (Safari) stack trace formats.
98pub fn parse_stack_trace(input: &str) -> Vec<StackFrame> {
99    parse_stack_trace_full(input).frames
100}
101
102/// Parse a stack trace string into message + frames.
103pub 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    // Detect engine and extract message from first line
109    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 the first line looks like a message (not a frame), save it
119    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
134/// Detect the JavaScript engine from the first line of a stack trace.
135fn 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        // Default to V8 for error message lines
145        Engine::V8
146    }
147}
148
149/// Check if a line looks like a stack frame (vs an error message).
150fn 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
158/// Parse a single stack frame line.
159fn 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
169/// Parse a V8 stack frame: `at functionName (file:line:column)` or `at file:line:column`
170fn parse_v8_frame(line: &str) -> Option<StackFrame> {
171    let rest = line.strip_prefix("at ")?;
172
173    // Check for `functionName (file:line:column)` format
174    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    // Bare `file:line:column` format
188    let (file, line_num, col) = parse_location(rest)?;
189    Some(StackFrame { function_name: None, file, line: line_num, column: col })
190}
191
192/// Parse a SpiderMonkey stack frame: `functionName@file:line:column`
193fn 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
205/// Parse a JavaScriptCore stack frame: `functionName@file:line:column`
206/// Same format as SpiderMonkey.
207fn parse_jsc_frame(line: &str) -> Option<StackFrame> {
208    parse_spidermonkey_frame(line)
209}
210
211/// Parse a location string: `file:line:column` or `file:line`
212/// Handles URLs with colons (http://host:port/file:line:column)
213fn parse_location(location: &str) -> Option<(String, u32, u32)> {
214    // Split from the right to handle URLs with colons
215    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
228// ── Symbolication ───────────────────────────────────────────────
229
230/// Symbolicate a stack trace using a source map loader function.
231///
232/// The `loader` is called with each unique source file and should return
233/// the corresponding `SourceMap`, or `None` if not available.
234///
235/// Stack trace lines/columns are 1-based; source maps use 0-based internally.
236pub 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
244/// Symbolicate pre-parsed frames.
245fn 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                // Stack traces are 1-based, source maps are 0-based
262                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,     // back to 1-based
273                        column: loc.column + 1, // back to 1-based
274                        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
300/// Batch symbolicate multiple stack traces against pre-loaded source maps.
301///
302/// `maps` is a map of source file → SourceMap. All stack traces are resolved
303/// against these pre-loaded maps without additional loading.
304pub 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
311/// Resolve a debug ID to a source map from a set of maps indexed by debug ID.
312///
313/// Useful for error monitoring systems where source maps are identified by
314/// their debug ID rather than by filename.
315pub 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
322/// Serialize a symbolicated stack to JSON.
323pub 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// ── Tests ───────────────────────────────────────────────────────
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351
352    // ── V8 format tests ──────────────────────────────────────────
353
354    #[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    // ── SpiderMonkey format tests ────────────────────────────────
384
385    #[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    // ── Symbolication tests ──────────────────────────────────────
411
412    #[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        // Lines that don't match any frame format
529        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        // JSC: has @ but no : or / (just @ sign alone)
538        let input = "someFunc@native code";
539        let frames = parse_stack_trace(input);
540        // Should detect as JSC and try to parse
541        assert!(frames.is_empty() || frames[0].function_name.as_deref() == Some("someFunc"));
542    }
543
544    #[test]
545    fn parse_v8_bare_location() {
546        // V8 bare format: `at file:line:column` (no parens, no function name)
547        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        // V8 with empty function name before parens
559        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        // SpiderMonkey: @file:line:col with empty function name
568        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        // parse_location returns None when file component is empty
578        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        // Source map exists but no mapping at the requested position
602        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        // Position 99:99 (0-based) is beyond any mapping, frame should not be symbolicated
609        // Actually it may snap to closest - let's check either way
610        assert!(!result.frames[0].file.is_empty());
611    }
612
613    #[test]
614    fn symbolicate_caches_source_maps() {
615        use std::cell::Cell;
616
617        // Multiple frames from the same file should only call the loader once
618        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        // Both should be resolved
629        assert!(result.frames[0].symbolicated);
630        assert!(result.frames[1].symbolicated);
631    }
632
633    #[test]
634    fn parse_default_engine_detection() {
635        // First line is just an error message, no frame indicators
636        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        // V8 bare format with a URL containing a port
684        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        // SpiderMonkey stack where the second line (after engine detection from first
696        // frame line) goes through parse_spidermonkey_frame
697        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        // First line is itself a V8 frame (contains "    at ")
722        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        // JSC: has @ but no colon or slash
729        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        // If column is not a number, parse_location should return None
736        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        // If line is not a number, parse_location should return None
743        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}