use fancy_regex::Regex;
use rich::{Console, ConsoleOptions, Renderable, Segment, Text};
use std::sync::OnceLock;
use crate::event::theme_style;
use crate::hyperlink::Hyperlinker;
pub const MAX_CAUSES: usize = 64;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Language {
Rust,
Python,
Java,
JavaScript,
Other(String),
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Frame {
pub function: Option<String>,
pub path: Option<String>,
pub line: Option<usize>,
pub column: Option<usize>,
pub source: Option<String>,
pub metadata: Vec<(String, String)>,
pub library: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CauseKind {
CausedBy,
DuringHandling,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StackTrace {
pub language: Language,
pub kind: Option<String>,
pub message: Option<String>,
pub frames: Vec<Frame>,
pub cause: Option<Box<StackTrace>>,
pub cause_kind: CauseKind,
pub omitted_causes: usize,
pub location: Option<Frame>,
}
impl StackTrace {
pub fn new(language: Language) -> Self {
StackTrace {
language,
kind: None,
message: None,
frames: Vec::new(),
cause: None,
cause_kind: CauseKind::CausedBy,
omitted_causes: 0,
location: None,
}
}
pub fn chain(&self) -> impl Iterator<Item = &StackTrace> {
std::iter::successors(Some(self), |trace| trace.cause.as_deref())
}
pub fn origin(&self) -> Option<&Frame> {
self.frames.iter().rev().find(|frame| !frame.library)
}
pub fn render_options(&self) -> StackTraceView<'_> {
StackTraceView {
trace: self,
linker: Hyperlinker::new(),
show_library: false,
}
}
}
impl Drop for StackTrace {
fn drop(&mut self) {
let mut next = self.cause.take();
while let Some(mut trace) = next {
next = trace.cause.take();
}
}
}
fn link_causes(mut traces: Vec<StackTrace>) -> Option<StackTrace> {
let omitted = traces.len().saturating_sub(MAX_CAUSES + 1);
traces.truncate(MAX_CAUSES + 1);
if let Some(last) = traces.last_mut() {
last.omitted_causes += omitted;
}
let mut result: Option<StackTrace> = None;
for mut trace in traces.into_iter().rev() {
trace.cause = result.take().map(Box::new);
result = Some(trace);
}
result
}
pub trait TraceParser: Send + Sync {
fn detect(&self, text: &str) -> bool;
fn parse(&self, text: &str) -> Option<StackTrace>;
}
pub struct Parsers {
parsers: Vec<Box<dyn TraceParser>>,
}
impl Default for Parsers {
fn default() -> Self {
Parsers {
parsers: vec![
Box::new(PythonParser),
Box::new(JavaParser),
Box::new(RustParser),
Box::new(JavaScriptParser),
],
}
}
}
impl Parsers {
pub fn new() -> Self {
Parsers::default()
}
pub fn with_parser(mut self, parser: impl TraceParser + 'static) -> Self {
self.parsers.insert(0, Box::new(parser));
self
}
pub fn parse(&self, text: &str) -> Option<StackTrace> {
self.parsers
.iter()
.find(|parser| parser.detect(text))
.and_then(|parser| parser.parse(text))
}
}
pub fn parse(text: &str) -> Option<StackTrace> {
Parsers::default().parse(text)
}
fn regex(cell: &'static OnceLock<Regex>, pattern: &str) -> &'static Regex {
cell.get_or_init(|| Regex::new(pattern).expect("valid trace pattern"))
}
fn number(value: Option<fancy_regex::Match<'_>>) -> Option<usize> {
value.and_then(|value| value.as_str().parse().ok())
}
fn kind_and_message(line: &str) -> (Option<String>, Option<String>) {
let line = line.trim();
match line.split_once(": ") {
Some((kind, message)) if !kind.contains(' ') => {
(Some(kind.to_string()), Some(message.to_string()))
}
_ if !line.contains(' ') && !line.is_empty() => (Some(line.to_string()), None),
_ => (None, Some(line.to_string()).filter(|line| !line.is_empty())),
}
}
pub struct RustParser;
impl RustParser {
fn is_library(function: &str, path: Option<&str>) -> bool {
const CRATES: [&str; 6] = [
"std::",
"core::",
"alloc::",
"backtrace::",
"rust_begin_unwind",
"__rust",
];
CRATES.iter().any(|prefix| {
function.starts_with(prefix) || function.starts_with(&format!("<{prefix}"))
}) || path.is_some_and(|path| {
path.starts_with("/rustc/")
|| path.contains("/.cargo/registry/")
|| path.contains("\\.cargo\\registry\\")
})
}
}
impl TraceParser for RustParser {
fn detect(&self, text: &str) -> bool {
static FRAME: OnceLock<Regex> = OnceLock::new();
text.contains("panicked at")
|| regex(&FRAME, r"(?m)^\s*\d+: \S")
.is_match(text)
.unwrap_or(false)
}
fn parse(&self, text: &str) -> Option<StackTrace> {
static FRAME: OnceLock<Regex> = OnceLock::new();
static AT: OnceLock<Regex> = OnceLock::new();
static PANIC: OnceLock<Regex> = OnceLock::new();
static OLD_PANIC: OnceLock<Regex> = OnceLock::new();
let frame_re = regex(&FRAME, r"^\s*\d+:\s+(.+?)\s*$");
let at_re = regex(&AT, r"^\s+at\s+(.+?):(\d+)(?::(\d+))?\s*$");
let mut trace = StackTrace::new(Language::Rust);
let lines: Vec<&str> = text.lines().collect();
let panic_re = regex(&PANIC, r"panicked at (.+?):(\d+):(\d+):\s*$");
let old_re = regex(&OLD_PANIC, r"panicked at '(.*)', (.+?):(\d+):(\d+)");
let mut index = 0;
while index < lines.len() {
let line = lines[index];
if let Ok(Some(caps)) = old_re.captures(line) {
trace.kind = Some("panic".into());
trace.message = Some(caps[1].to_string());
trace.metadata_location(&caps[2], number(caps.get(3)), number(caps.get(4)));
} else if let Ok(Some(caps)) = panic_re.captures(line) {
trace.kind = Some("panic".into());
trace.metadata_location(&caps[1], number(caps.get(2)), number(caps.get(3)));
let mut message = Vec::new();
while index + 1 < lines.len()
&& !lines[index + 1].starts_with("stack backtrace:")
&& !lines[index + 1].starts_with("note:")
&& !frame_re.is_match(lines[index + 1]).unwrap_or(false)
{
index += 1;
message.push(lines[index]);
}
trace.message = Some(message.join("\n")).filter(|message| !message.is_empty());
} else if let Ok(Some(caps)) = frame_re.captures(line) {
let mut function = caps[1].to_string();
if let Some(stripped) = function.rsplit_once("::h").filter(|(_, hash)| {
hash.len() == 16 && hash.chars().all(|c| c.is_ascii_hexdigit())
}) {
function = stripped.0.to_string();
}
let mut frame = Frame {
function: Some(function),
..Frame::default()
};
let next = lines.get(index + 1).copied().unwrap_or("");
if let Ok(Some(at)) = at_re.captures(next) {
frame.path = at.get(1).map(|path| path.as_str().to_string());
frame.line = number(at.get(2));
frame.column = number(at.get(3));
index += 1;
}
frame.library = Self::is_library(
frame.function.as_deref().unwrap_or(""),
frame.path.as_deref(),
);
trace.frames.push(frame);
}
index += 1;
}
trace.frames.reverse();
(trace.kind.is_some() || !trace.frames.is_empty()).then_some(trace)
}
}
impl StackTrace {
fn metadata_location(&mut self, path: &str, line: Option<usize>, column: Option<usize>) {
self.location = Some(Frame {
path: Some(path.to_string()),
line,
column,
..Frame::default()
});
}
}
pub struct PythonParser;
const PY_CAUSE: &str = "The above exception was the direct cause of the following exception:";
const PY_CONTEXT: &str = "During handling of the above exception, another exception occurred:";
impl PythonParser {
fn block(text: &str) -> Option<StackTrace> {
static FILE: OnceLock<Regex> = OnceLock::new();
let file_re = regex(&FILE, r#"^\s*File "(.+?)", line (\d+)(?:, in (.+))?$"#);
let mut trace = StackTrace::new(Language::Python);
let mut last_line = None;
let lines: Vec<&str> = text.lines().collect();
let mut index = 0;
while index < lines.len() {
let line = lines[index];
if let Ok(Some(caps)) = file_re.captures(line) {
let path = caps[1].to_string();
let library = path.contains("site-packages")
|| path.contains("/lib/python")
|| path.starts_with('<');
let mut frame = Frame {
function: caps.get(3).map(|m| m.as_str().to_string()),
path: Some(path),
line: number(caps.get(2)),
library,
..Frame::default()
};
while let Some(next) = lines.get(index + 1) {
let trimmed = next.trim();
if next.starts_with(" ")
&& !trimmed.is_empty()
&& !file_re.is_match(next).unwrap_or(false)
{
if trimmed.chars().all(|c| matches!(c, '^' | '~' | ' ')) {
index += 1;
continue;
}
if frame.source.is_none() {
frame.source = Some(trimmed.to_string());
}
index += 1;
} else {
break;
}
}
trace.frames.push(frame);
} else if !line.trim().is_empty()
&& !line.starts_with("Traceback (most recent call last):")
&& !line.starts_with(' ')
{
last_line = Some(index);
}
index += 1;
}
if let Some(last) = last_line {
let rest = lines[last..].join("\n");
let (kind, message) = kind_and_message(&rest);
trace.kind = kind;
trace.message = message;
}
(trace.kind.is_some() || !trace.frames.is_empty()).then_some(trace)
}
}
impl TraceParser for PythonParser {
fn detect(&self, text: &str) -> bool {
text.contains("Traceback (most recent call last):")
}
fn parse(&self, text: &str) -> Option<StackTrace> {
let mut blocks: Vec<(String, Option<CauseKind>)> = Vec::new();
let mut current = String::new();
let mut kind = None;
for line in text.lines() {
let relation = match line.trim() {
PY_CAUSE => Some(CauseKind::CausedBy),
PY_CONTEXT => Some(CauseKind::DuringHandling),
_ => None,
};
if let Some(relation) = relation {
blocks.push((std::mem::take(&mut current), kind));
kind = Some(relation);
} else {
current.push_str(line);
current.push('\n');
}
}
blocks.push((current, kind));
let mut chain: Vec<StackTrace> = Vec::new();
for (block, relation) in blocks {
let Some(mut trace) = Self::block(&block) else {
continue;
};
match relation {
Some(relation) if !chain.is_empty() => trace.cause_kind = relation,
_ => chain.clear(),
}
chain.push(trace);
}
chain.reverse();
link_causes(chain)
}
}
pub struct JavaParser;
impl TraceParser for JavaParser {
fn detect(&self, text: &str) -> bool {
static AT: OnceLock<Regex> = OnceLock::new();
regex(&AT, r"(?m)^\s+at [\w$.<>]+\(.*\)\s*$")
.is_match(text)
.unwrap_or(false)
}
fn parse(&self, text: &str) -> Option<StackTrace> {
static AT: OnceLock<Regex> = OnceLock::new();
let at_re = regex(&AT, r"^\s+at (?:[\w.]+/)?([\w$.<>]+)\((.*)\)\s*$");
let mut traces: Vec<StackTrace> = Vec::new();
let mut header: Option<&str> = None;
for line in text.lines() {
if let Ok(Some(caps)) = at_re.captures(line) {
if traces.is_empty() {
let line = header.unwrap_or("").trim();
let line = line
.strip_prefix("Exception in thread ")
.and_then(|rest| rest.split_once("\" ").map(|(_, rest)| rest))
.unwrap_or(line);
let mut trace = StackTrace::new(Language::Java);
(trace.kind, trace.message) = kind_and_message(line);
traces.push(trace);
}
let Some(trace) = traces.last_mut() else {
continue;
};
let function = caps[1].to_string();
let location = &caps[2];
let mut frame = Frame {
library: ["java.", "javax.", "jdk.", "sun.", "com.sun.", "kotlin."]
.iter()
.any(|prefix| function.starts_with(prefix)),
function: Some(function),
..Frame::default()
};
match location.rsplit_once(':') {
Some((file, line)) if line.parse::<usize>().is_ok() => {
frame.path = Some(file.to_string());
frame.line = line.parse().ok();
}
_ if location == "Native Method" => {
frame.metadata.push(("native".into(), "true".into()));
}
_ if location != "Unknown Source" && !location.is_empty() => {
frame.path = Some(location.to_string());
}
_ => {}
}
trace.frames.push(frame);
} else if let Some(rest) = line.trim_start().strip_prefix("Caused by: ") {
let mut trace = StackTrace::new(Language::Java);
(trace.kind, trace.message) = kind_and_message(rest);
traces.push(trace);
} else if line.trim_start().starts_with("... ") && line.trim_end().ends_with(" more") {
if let Some(trace) = traces.last_mut() {
trace.frames.push(Frame {
metadata: vec![("elided".into(), line.trim().to_string())],
library: true,
..Frame::default()
});
}
} else if traces.is_empty() && !line.trim().is_empty() {
header = Some(line);
}
}
for trace in &mut traces {
trace.frames.reverse();
}
link_causes(traces)
}
}
pub struct JavaScriptParser;
impl TraceParser for JavaScriptParser {
fn detect(&self, text: &str) -> bool {
static AT: OnceLock<Regex> = OnceLock::new();
regex(&AT, r"(?m)^\s+at .*:\d+:\d+\)?(?: \{)?\s*$")
.is_match(text)
.unwrap_or(false)
}
fn parse(&self, text: &str) -> Option<StackTrace> {
static AT: OnceLock<Regex> = OnceLock::new();
let at_re = regex(
&AT,
r"^\s+at (?:(?:async )?(.+?) \()?(.+?):(\d+):(\d+)\)?(?: \{)?\s*$",
);
let mut traces: Vec<StackTrace> = Vec::new();
let mut header: Option<&str> = None;
for line in text.lines() {
if let Ok(Some(caps)) = at_re.captures(line) {
if traces.is_empty() {
let mut trace = StackTrace::new(Language::JavaScript);
(trace.kind, trace.message) = kind_and_message(header.unwrap_or(""));
traces.push(trace);
}
let Some(trace) = traces.last_mut() else {
continue;
};
let path = caps[2].to_string();
trace.frames.push(Frame {
library: path.starts_with("node:") || path.contains("node_modules"),
function: caps.get(1).map(|m| m.as_str().to_string()),
path: Some(path),
line: number(caps.get(3)),
column: number(caps.get(4)),
..Frame::default()
});
} else if let Some(rest) = line.trim_start().strip_prefix("[cause]: ") {
let mut trace = StackTrace::new(Language::JavaScript);
(trace.kind, trace.message) = kind_and_message(rest.trim_end_matches(['{', ' ']));
traces.push(trace);
} else if line.trim_start().starts_with("... ") && line.trim_end().ends_with(" ...") {
if let Some(trace) = traces.last_mut() {
trace.frames.push(Frame {
metadata: vec![("elided".into(), line.trim().to_string())],
library: true,
..Frame::default()
});
}
} else if traces.is_empty() && !line.trim().is_empty() {
header = Some(line);
}
}
for trace in &mut traces {
trace.frames.reverse();
}
link_causes(traces)
}
}
pub fn capture(message: impl Into<String>) -> StackTrace {
let backtrace = std::backtrace::Backtrace::force_capture().to_string();
let mut trace = RustParser
.parse(&backtrace)
.unwrap_or_else(|| StackTrace::new(Language::Rust));
trace.kind = Some("panic".into());
trace.message = Some(message.into());
trace
}
pub fn panic_hook(
console: Console,
) -> impl Fn(&std::panic::PanicHookInfo<'_>) + Send + Sync + 'static {
let console = std::sync::Mutex::new(console);
move |info| {
let payload = info.payload();
let message = payload
.downcast_ref::<&str>()
.map(|message| message.to_string())
.or_else(|| payload.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "Box<dyn Any>".to_string());
let mut trace = capture(message);
if let Some(location) = info.location() {
trace.location = Some(Frame {
path: Some(location.file().to_string()),
line: Some(location.line() as usize),
column: Some(location.column() as usize),
..Frame::default()
});
}
let console = console.lock().unwrap_or_else(|e| e.into_inner());
console.print(&trace.render_options());
}
}
fn clean(text: &str) -> String {
crate::sanitize_terminal_controls(text)
}
pub struct StackTraceView<'a> {
trace: &'a StackTrace,
linker: Hyperlinker,
show_library: bool,
}
impl StackTraceView<'_> {
pub fn hyperlinker(mut self, linker: Hyperlinker) -> Self {
self.linker = linker;
self
}
pub fn show_library(mut self, show: bool) -> Self {
self.show_library = show;
self
}
fn append_chain(&self, console: &Console, text: &mut Text) {
let mut chain: Vec<&StackTrace> = Vec::new();
let mut omitted = 0usize;
for trace in self.trace.chain() {
if chain.len() <= MAX_CAUSES {
chain.push(trace);
} else {
omitted += 1;
}
}
if let Some(last) = chain.last() {
omitted += last.omitted_causes;
}
if omitted > 0 {
let label = if omitted == 1 { "cause" } else { "causes" };
text.append(
&format!("… {omitted} more {label}\n\n"),
Some(theme_style(console, "stacktrace.library", "dim").into()),
);
}
for (index, trace) in chain.iter().enumerate().rev() {
self.append_trace(console, text, trace);
let Some(caused) = index.checked_sub(1).map(|at| chain[at]) else {
continue;
};
let bridge = match caused.cause_kind {
CauseKind::CausedBy => "The error above caused the following error:",
CauseKind::DuringHandling => {
"The error below happened while handling the error above:"
}
};
text.append("\n", None);
text.append(
bridge,
Some(theme_style(console, "stacktrace.bridge", "italic dim").into()),
);
text.append("\n\n", None);
}
}
fn append_trace(&self, console: &Console, text: &mut Text, trace: &StackTrace) {
let dim = theme_style(console, "stacktrace.library", "dim");
let function_style = theme_style(console, "stacktrace.function", "green");
let location_style = theme_style(console, "stacktrace.location", "magenta");
let mut hidden = 0usize;
let flush_hidden = |text: &mut Text, hidden: &mut usize| {
if *hidden > 0 {
let label = if *hidden == 1 { "frame" } else { "frames" };
text.append(
&format!(" … {hidden} library {label}\n"),
Some(dim.clone().into()),
);
*hidden = 0;
}
};
for frame in &trace.frames {
if frame.library && !self.show_library {
hidden += 1;
continue;
}
flush_hidden(text, &mut hidden);
let base = if frame.library {
Some(dim.clone())
} else {
None
};
let name = frame.function.as_deref().unwrap_or("<unknown>");
let elided = frame.metadata.iter().find(|(key, _)| key == "elided");
text.append(" ", None);
match elided {
Some((_, label)) => text.append(&clean(label), Some(dim.clone().into())),
None => text.append(
&clean(name),
Some(base.clone().unwrap_or(function_style.clone()).into()),
),
}
text.append("\n", None);
if let Some(path) = &frame.path {
text.append(" at ", base.clone().map(Into::into));
let location = self.linker.location(
path,
frame.line,
frame.column,
base.clone().unwrap_or(location_style.clone()),
);
*text = std::mem::take(text).append_text(&location);
text.append("\n", None);
}
if let Some(source) = &frame.source {
text.append(
&format!(" {}\n", clean(source)),
base.clone().map(Into::into),
);
}
}
flush_hidden(text, &mut hidden);
let error_style = theme_style(console, "stacktrace.error", "bold red");
let kind = trace.kind.as_deref().unwrap_or("error");
text.append(&clean(kind), Some(error_style.into()));
if let Some(message) = &trace.message {
text.append(": ", None);
text.append(&clean(message), None);
}
if let Some(location) = &trace.location {
if let Some(path) = &location.path {
text.append("\n at ", None);
let linked =
self.linker
.location(path, location.line, location.column, location_style);
*text = std::mem::take(text).append_text(&linked);
}
}
}
}
impl Renderable for StackTraceView<'_> {
fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
let mut text = Text::new("");
self.append_chain(console, &mut text);
text.rich_render(console, options)
}
}
impl Renderable for StackTrace {
fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
self.render_options().rich_render(console, options)
}
}