Skip to content

Commit 08e80e2

Browse files
feat: resolve original function names via scopes proposal
When a mapping has no associated name, walk the scopes ranges tree at the generated position, skip hidden ranges, and return the innermost stack-frame scope's original name. Implements the FindOriginalFunctionName operation from the ECMA-426 scopes proposal so symbolicated frames pick up readable function names even when the mapping itself only carries location data.
1 parent d91fe12 commit 08e80e2

3 files changed

Lines changed: 150 additions & 1 deletion

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/symbolicate/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,5 @@ workspace = true
1515

1616
[dependencies]
1717
srcmap-sourcemap = { workspace = true }
18+
srcmap-scopes = { workspace = true }
1819
serde_json = { workspace = true }

crates/symbolicate/src/lib.rs

Lines changed: 148 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
use std::collections::HashMap;
1818
use std::fmt;
1919

20+
use srcmap_scopes::GeneratedRange;
2021
use srcmap_sourcemap::SourceMap;
2122

2223
// ── Types ───────────────────────────────────────────────────────
@@ -37,7 +38,7 @@ pub struct StackFrame {
3738
/// A symbolicated (resolved) stack frame.
3839
#[derive(Debug, Clone, PartialEq, Eq)]
3940
pub struct SymbolicatedFrame {
40-
/// Original function name from source map (or original function name).
41+
/// Original function name from source map mappings or scopes data.
4142
pub function_name: Option<String>,
4243
/// Resolved original source file.
4344
pub file: String,
@@ -267,6 +268,7 @@ where
267268
function_name: loc
268269
.name
269270
.map(|n| sm.name(n).to_string())
271+
.or_else(|| find_original_function_name(sm, line, column))
270272
.or_else(|| frame.function_name.clone()),
271273
file: sm.source(loc.source).to_string(),
272274
line: loc.line + 1, // back to 1-based
@@ -297,6 +299,59 @@ where
297299
SymbolicatedStack { message, frames: result_frames }
298300
}
299301

302+
fn find_original_function_name(sm: &SourceMap, line: u32, column: u32) -> Option<String> {
303+
let scopes = sm.scopes.as_ref()?;
304+
let mut path = Vec::new();
305+
if !collect_innermost_range_path(&scopes.ranges, line, column, &mut path) {
306+
return None;
307+
}
308+
309+
for range in path.iter().rev() {
310+
if !range.is_stack_frame || range.is_hidden {
311+
continue;
312+
}
313+
314+
let definition = match range.definition {
315+
Some(definition) => definition,
316+
None => continue,
317+
};
318+
let scope = scopes.original_scope_for_definition(definition)?;
319+
if let Some(name) = &scope.name {
320+
return Some(name.clone());
321+
}
322+
}
323+
324+
None
325+
}
326+
327+
fn collect_innermost_range_path<'a>(
328+
ranges: &'a [GeneratedRange],
329+
line: u32,
330+
column: u32,
331+
path: &mut Vec<&'a GeneratedRange>,
332+
) -> bool {
333+
for range in ranges {
334+
if !range_contains_position(range, line, column) {
335+
continue;
336+
}
337+
338+
path.push(range);
339+
if collect_innermost_range_path(&range.children, line, column, path) {
340+
return true;
341+
}
342+
return true;
343+
}
344+
345+
false
346+
}
347+
348+
fn range_contains_position(range: &GeneratedRange, line: u32, column: u32) -> bool {
349+
let pos = (line, column);
350+
let start = (range.start.line, range.start.column);
351+
let end = (range.end.line, range.end.column);
352+
start <= pos && pos <= end
353+
}
354+
300355
/// Batch symbolicate multiple stack traces against pre-loaded source maps.
301356
///
302357
/// `maps` is a map of source file → SourceMap. All stack traces are resolved
@@ -348,6 +403,7 @@ pub fn to_json(stack: &SymbolicatedStack) -> String {
348403
#[cfg(test)]
349404
mod tests {
350405
use super::*;
406+
use srcmap_scopes::{GeneratedRange, OriginalScope, Position, ScopeInfo};
351407

352408
// ── V8 format tests ──────────────────────────────────────────
353409

@@ -426,6 +482,97 @@ mod tests {
426482
assert_eq!(result.frames[0].function_name.as_deref(), Some("handleClick"));
427483
}
428484

485+
#[test]
486+
fn symbolicate_uses_scopes_function_name_when_mapping_name_is_absent() {
487+
let scope_info = ScopeInfo {
488+
scopes: vec![Some(OriginalScope {
489+
start: Position { line: 10, column: 0 },
490+
end: Position { line: 20, column: 0 },
491+
name: Some("originalFunc".to_string()),
492+
kind: Some("function".to_string()),
493+
is_stack_frame: true,
494+
variables: vec![],
495+
children: vec![],
496+
})],
497+
ranges: vec![GeneratedRange {
498+
start: Position { line: 0, column: 0 },
499+
end: Position { line: 0, column: 10 },
500+
is_stack_frame: true,
501+
is_hidden: false,
502+
definition: Some(0),
503+
call_site: None,
504+
bindings: vec![],
505+
children: vec![],
506+
}],
507+
};
508+
let mut names = vec![];
509+
let scopes_str = srcmap_scopes::encode_scopes(&scope_info, &mut names);
510+
let names_json = serde_json::to_string(&names).unwrap();
511+
let map_json = format!(
512+
r#"{{"version":3,"sources":["src/app.ts"],"names":{names_json},"mappings":"AAAA","scopes":"{scopes_str}"}}"#
513+
);
514+
515+
let result = symbolicate("Error: test\n at foo (bundle.js:1:1)", |file| {
516+
if file == "bundle.js" { SourceMap::from_json(&map_json).ok() } else { None }
517+
});
518+
519+
assert_eq!(result.frames[0].function_name.as_deref(), Some("originalFunc"));
520+
}
521+
522+
#[test]
523+
fn symbolicate_skips_hidden_scopes_and_uses_outer_stack_frame_name() {
524+
let scope_info = ScopeInfo {
525+
scopes: vec![Some(OriginalScope {
526+
start: Position { line: 0, column: 0 },
527+
end: Position { line: 30, column: 0 },
528+
name: Some("outerFunc".to_string()),
529+
kind: Some("function".to_string()),
530+
is_stack_frame: true,
531+
variables: vec![],
532+
children: vec![OriginalScope {
533+
start: Position { line: 5, column: 0 },
534+
end: Position { line: 10, column: 0 },
535+
name: Some("hiddenInner".to_string()),
536+
kind: Some("function".to_string()),
537+
is_stack_frame: true,
538+
variables: vec![],
539+
children: vec![],
540+
}],
541+
})],
542+
ranges: vec![GeneratedRange {
543+
start: Position { line: 0, column: 0 },
544+
end: Position { line: 0, column: 20 },
545+
is_stack_frame: true,
546+
is_hidden: false,
547+
definition: Some(0),
548+
call_site: None,
549+
bindings: vec![],
550+
children: vec![GeneratedRange {
551+
start: Position { line: 0, column: 5 },
552+
end: Position { line: 0, column: 10 },
553+
is_stack_frame: true,
554+
is_hidden: true,
555+
definition: Some(1),
556+
call_site: None,
557+
bindings: vec![],
558+
children: vec![],
559+
}],
560+
}],
561+
};
562+
let mut names = vec![];
563+
let scopes_str = srcmap_scopes::encode_scopes(&scope_info, &mut names);
564+
let names_json = serde_json::to_string(&names).unwrap();
565+
let map_json = format!(
566+
r#"{{"version":3,"sources":["src/app.ts"],"names":{names_json},"mappings":"AAAA","scopes":"{scopes_str}"}}"#
567+
);
568+
569+
let result = symbolicate("Error: test\n at foo (bundle.js:1:7)", |file| {
570+
if file == "bundle.js" { SourceMap::from_json(&map_json).ok() } else { None }
571+
});
572+
573+
assert_eq!(result.frames[0].function_name.as_deref(), Some("outerFunc"));
574+
}
575+
429576
#[test]
430577
fn symbolicate_no_map() {
431578
let stack = "Error: test\n at foo (unknown.js:10:5)";

0 commit comments

Comments
 (0)