///|
/// A generated stack frame accepted by the symbolizer.
pub(all) struct StackFrame {
file : String
line : Int
column : Int
function_name : String?
raw : String
} derive(Eq, Debug)
///|
/// A generated frame and its optional original location.
pub(all) struct SymbolizedFrame {
generated : StackFrame
source : String?
line : Int?
column : Int?
name : String?
ignored : Bool
matched : Bool
} derive(Eq, Debug)
///|
fn parse_decimal(value : StringView) -> Int? {
if value.is_empty() {
return None
}
let mut result = 0
for ch in value {
if ch < '0' || ch > '9' {
return None
}
let digit = ch.to_int() - '0'.to_int()
if result > (0x7fffffff - digit) / 10 {
return None
}
result = result * 10 + digit
}
Some(result)
}
///|
fn strip_frame_wrapper(value : String) -> (String, String?) {
let trimmed = value.trim().to_owned()
if trimmed.has_prefix("at ") {
let body = trimmed[3:].trim()
match body.rev_split_once(" (") {
Some((name, location)) =>
if location.has_suffix(")") {
(location[:location.length() - 1].to_owned(), Some(name.to_owned()))
} else {
(body.to_owned(), None)
}
None => (body.to_owned(), None)
}
} else {
(trimmed, None)
}
}
///|
/// Parse `file:line:column` and common `at name (file:line:column)` frames.
///
/// Coordinates are zero-based, matching the library API and ECMA-426.
/// Splitting from the right preserves Windows drive letters and URI schemes.
pub fn parse_stack_frame(input : String) -> StackFrame raise SourceMapError {
let (location, function_name) = strip_frame_wrapper(input)
let (file_and_line, column_text) = match location.rev_split_once(":") {
Some(pair) => pair
None =>
raise InvalidField(path="$frame", message="expected file:line:column")
}
let (file, line_text) = match file_and_line.rev_split_once(":") {
Some(pair) => pair
None =>
raise InvalidField(path="$frame", message="expected file:line:column")
}
if file.is_empty() {
raise InvalidField(path="$frame.file", message="file must not be empty")
}
let line = match parse_decimal(line_text) {
Some(line) => line
None =>
raise InvalidField(
path="$frame.line",
message="line must be a non-negative integer",
)
}
let column = match parse_decimal(column_text) {
Some(column) => column
None =>
raise InvalidField(
path="$frame.column",
message="column must be a non-negative integer",
)
}
{ file: file.to_owned(), line, column, function_name, raw: input }
}
///|
fn unmatched_frame(frame : StackFrame) -> SymbolizedFrame {
{
generated: frame,
source: None,
line: None,
column: None,
name: None,
ignored: false,
matched: false,
}
}
///|
/// Symbolize one generated frame.
pub fn symbolize_frame(
map : DecodedSourceMap,
frame : StackFrame,
bias? : LookupBias = GreatestLowerBound,
) -> SymbolizedFrame {
let mapping = original_position_for(
map,
generated=Position::new(line=frame.line, column=frame.column),
bias~,
)
match mapping {
Some(mapping) =>
match mapping.original {
Some(original) =>
if original.source_index >= 0 &&
original.source_index < map.sources.length() {
let source = map.sources[original.source_index]
{
generated: frame,
source: source.url,
line: Some(original.line),
column: Some(original.column),
name: match mapping.name {
Some(name) => Some(name)
None => frame.function_name
},
ignored: source.ignored,
matched: true,
}
} else {
unmatched_frame(frame)
}
None => unmatched_frame(frame)
}
None => unmatched_frame(frame)
}
}
///|
/// Parse and symbolize newline-separated stack frames.
///
/// Empty lines are ignored. A malformed non-empty line is reported as an
/// error, preventing CI from silently accepting partially processed input.
pub fn symbolize(
map : DecodedSourceMap,
frames : String,
bias? : LookupBias = GreatestLowerBound,
) -> Array[SymbolizedFrame] raise SourceMapError {
let result : Array[SymbolizedFrame] = []
for line in frames.split("\n") {
let text = line.trim()
if text.is_empty() {
continue
}
result.push(symbolize_frame(map, parse_stack_frame(text.to_owned()), bias~))
}
result
}
///|
fn frame_json_string(value : String?) -> Json {
match value {
Some(value) => Json::string(value)
None => Json::null()
}
}
///|
fn frame_json_number(value : Int?) -> Json {
match value {
Some(value) => Json::number(value.to_double())
None => Json::null()
}
}
///|
/// Convert a symbolized frame to stable machine-readable JSON.
pub fn SymbolizedFrame::to_json(self : SymbolizedFrame) -> Json {
Json::object({
"generated": Json::object({
"file": Json::string(self.generated.file),
"line": Json::number(self.generated.line.to_double()),
"column": Json::number(self.generated.column.to_double()),
"name": frame_json_string(self.generated.function_name),
}),
"original": if self.matched {
Json::object({
"source": frame_json_string(self.source),
"line": frame_json_number(self.line),
"column": frame_json_number(self.column),
"name": frame_json_string(self.name),
"ignored": if self.ignored {
Json::boolean(true)
} else {
Json::boolean(false)
},
})
} else {
Json::null()
},
"matched": if self.matched {
Json::boolean(true)
} else {
Json::boolean(false)
},
})
}
///|
/// Render a concise human-readable symbolized frame.
pub fn SymbolizedFrame::render(self : SymbolizedFrame) -> String {
let generated = "\{self.generated.file}:\{self.generated.line}:\{self.generated.column}"
if !self.matched {
generated + " -> "
} else {
let source = self.source.unwrap_or("")
let line = self.line.unwrap_or(0)
let column = self.column.unwrap_or(0)
let name = match self.name {
Some(name) => " " + name
None => ""
}
generated + " -> " + source + ":\{line}:\{column}" + name
}
}
///|
/// Convert a list of symbolized frames to JSON.
pub fn symbolized_frames_to_json(frames : ArrayView[SymbolizedFrame]) -> Json {
Json::array(frames.map(frame => frame.to_json()))
}