use std::sync::LazyLock;
use regex::Regex;
use crate::console::{Console, ConsoleOptions, Renderable};
use crate::panel::Panel;
use crate::segment::Segment;
use crate::style::Style;
#[cfg(feature = "syntax")]
use crate::syntax::Syntax;
use crate::text::{Text, TextPart};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Frame {
pub filename: String,
pub lineno: Option<usize>,
pub name: String,
pub source_line: Option<String>,
}
impl Frame {
pub fn new(filename: &str, lineno: Option<usize>, name: &str) -> Self {
Frame {
filename: filename.to_string(),
lineno,
name: name.to_string(),
source_line: None,
}
}
#[must_use]
pub fn with_source_line(mut self, line: &str) -> Self {
self.source_line = Some(line.to_string());
self
}
pub fn read_source_line(&mut self) {
if self.source_line.is_some() {
return;
}
if let Some(lineno) = self.lineno {
if lineno == 0 {
return;
}
let path = std::path::Path::new(&self.filename);
if path.is_absolute() || self.filename.starts_with("./") {
if let Ok(contents) = std::fs::read_to_string(path) {
if let Some(line) = contents.lines().nth(lineno - 1) {
self.source_line = Some(line.to_string());
}
}
}
}
}
}
impl std::fmt::Display for Frame {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.lineno {
Some(n) => write!(f, " {} ({}:{})", self.name, self.filename, n),
None => write!(f, " {} ({})", self.name, self.filename),
}
}
}
#[derive(Debug, Clone)]
pub struct Traceback {
pub title: String,
pub message: String,
pub frames: Vec<Frame>,
pub show_locals: bool,
pub width: Option<usize>,
pub extra_lines: usize,
pub theme: String,
pub word_wrap: bool,
pub max_frames: usize,
pub suppress_paths: Vec<String>,
}
impl Traceback {
pub fn new() -> Self {
Traceback {
title: String::new(),
message: String::new(),
frames: Vec::new(),
show_locals: false,
width: None,
extra_lines: 3,
theme: "base16-ocean.dark".to_string(),
word_wrap: true,
max_frames: 100,
suppress_paths: Vec::new(),
}
}
pub fn from_backtrace(bt: &str) -> Self {
let frames = parse_backtrace(bt);
Traceback {
title: "Backtrace".to_string(),
message: String::new(),
frames,
..Traceback::new()
}
}
pub fn from_error(error: &dyn std::error::Error) -> Self {
let title = format!("{}", error);
let mut chain_messages: Vec<String> = Vec::new();
let mut current = error.source();
while let Some(cause) = current {
chain_messages.push(format!("{}", cause));
current = cause.source();
}
let message = if chain_messages.is_empty() {
String::new()
} else {
format!("Caused by:\n {}", chain_messages.join("\n "))
};
Traceback {
title: error_type_name(error),
message: format!(
"{}{}{}",
title,
if message.is_empty() { "" } else { "\n" },
message
),
frames: Vec::new(),
..Traceback::new()
}
}
pub fn from_panic(message: &str, backtrace: &str) -> Self {
let frames = parse_backtrace(backtrace);
Traceback {
title: "Panic".to_string(),
message: message.to_string(),
frames,
..Traceback::new()
}
}
#[must_use]
pub fn with_title(mut self, title: &str) -> Self {
self.title = title.to_string();
self
}
#[must_use]
pub fn with_message(mut self, message: &str) -> Self {
self.message = message.to_string();
self
}
#[must_use]
pub fn with_show_locals(mut self, show: bool) -> Self {
self.show_locals = show;
self
}
#[must_use]
pub fn with_width(mut self, width: usize) -> Self {
self.width = Some(width);
self
}
#[must_use]
pub fn with_extra_lines(mut self, lines: usize) -> Self {
self.extra_lines = lines;
self
}
#[must_use]
pub fn with_theme(mut self, theme: &str) -> Self {
self.theme = theme.to_string();
self
}
#[must_use]
pub fn with_word_wrap(mut self, wrap: bool) -> Self {
self.word_wrap = wrap;
self
}
#[must_use]
pub fn with_max_frames(mut self, max: usize) -> Self {
self.max_frames = max;
self
}
#[must_use]
pub fn with_suppress(mut self, paths: Vec<String>) -> Self {
self.suppress_paths = paths;
self
}
#[cfg(test)]
fn visible_frames(&self) -> Vec<&Frame> {
if self.suppress_paths.is_empty() {
return self.frames.iter().collect();
}
self.frames
.iter()
.filter(|f| {
!self
.suppress_paths
.iter()
.any(|p| f.filename.contains(p.as_str()))
})
.collect()
}
pub fn install_panic_hook() {
Self::install_panic_hook_with(Vec::new());
}
pub fn install_panic_hook_with(suppress_paths: Vec<String>) {
std::panic::set_hook(Box::new(move |info| {
let message = if let Some(s) = info.payload().downcast_ref::<&str>() {
s.to_string()
} else if let Some(s) = info.payload().downcast_ref::<String>() {
s.clone()
} else {
"Box<dyn Any>".to_string()
};
let full_message = if let Some(loc) = info.location() {
format!("{} ({}:{})", message, loc.file(), loc.line())
} else {
message
};
let bt = std::backtrace::Backtrace::force_capture();
let bt_str = bt.to_string();
let tb =
Traceback::from_panic(&full_message, &bt_str).with_suppress(suppress_paths.clone());
let mut console = Console::builder().no_color(false).build();
console.begin_capture();
console.print(&tb);
let rendered = console.end_capture();
use std::io::Write as _;
let _ = std::io::stderr().write_all(rendered.as_bytes());
let _ = std::io::stderr().flush();
}));
}
#[cfg(test)]
fn render_content(&self) -> Text {
let mut parts: Vec<TextPart> = Vec::new();
let visible: Vec<&Frame> = self.visible_frames();
if !self.frames.is_empty() && visible.is_empty() {
let n = self.frames.len();
let msg = format!(
"[suppressed {} frame{}]\n",
n,
if n == 1 { "" } else { "s" }
);
parts.push(TextPart::Styled(msg, Style::parse("dim italic")));
return Text::assemble(&parts, Style::null());
}
let frame_count = visible.len();
let show_count = frame_count.min(self.max_frames);
let truncated = frame_count > self.max_frames;
let indices: Vec<usize> = if truncated {
let half = self.max_frames / 2;
let mut idx: Vec<usize> = (0..half).collect();
idx.extend(frame_count - half..frame_count);
idx
} else {
(0..frame_count).collect()
};
let mut inserted_ellipsis = false;
for (pos, &frame_idx) in indices.iter().enumerate() {
if truncated && !inserted_ellipsis && frame_idx >= self.max_frames / 2 {
inserted_ellipsis = true;
let omitted = frame_count - show_count;
let msg = format!("\n ... {} frames omitted ...\n", omitted);
parts.push(TextPart::Styled(msg, Style::parse("dim italic")));
}
let frame = visible[frame_idx];
let location = match frame.lineno {
Some(n) => format!("{}:{}", frame.filename, n),
None => frame.filename.clone(),
};
parts.push(TextPart::Styled(
format!(" File \"{}\"", location),
Style::parse("green"),
));
parts.push(TextPart::Styled(
format!(", in {}", frame.name),
Style::parse("magenta"),
));
parts.push(TextPart::Raw("\n".to_string()));
if let Some(ref source) = frame.source_line {
let trimmed = source.trim();
if !trimmed.is_empty() {
parts.push(TextPart::Raw(format!(" {}", trimmed)));
parts.push(TextPart::Raw("\n".to_string()));
}
}
if pos + 1 < indices.len() {
parts.push(TextPart::Raw("\n".to_string()));
}
}
Text::assemble(&parts, Style::null())
}
}
impl Default for Traceback {
fn default() -> Self {
Self::new()
}
}
impl std::fmt::Display for Traceback {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if !self.title.is_empty() {
writeln!(f, "{}", self.title)?;
}
let suppressed: Vec<&Frame> = if self.suppress_paths.is_empty() {
self.frames.iter().collect()
} else {
self.frames
.iter()
.filter(|fr| {
!self
.suppress_paths
.iter()
.any(|p| fr.filename.contains(p.as_str()))
})
.collect()
};
if !self.frames.is_empty() && suppressed.is_empty() {
let n = self.frames.len();
writeln!(
f,
"[suppressed {} frame{}]",
n,
if n == 1 { "" } else { "s" }
)?;
} else {
for frame in &suppressed {
writeln!(f, "{}", frame)?;
}
}
if !self.message.is_empty() {
write!(f, "{}", self.message)?;
}
Ok(())
}
}
impl Renderable for Traceback {
fn gilt_console(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
#[cfg(feature = "syntax")]
let panel_width = self.width.unwrap_or(options.max_width);
let mut content_parts: Vec<TextPart> = Vec::new();
let visible_frames: Vec<&Frame> = if self.suppress_paths.is_empty() {
self.frames.iter().collect()
} else {
self.frames
.iter()
.filter(|f| {
!self
.suppress_paths
.iter()
.any(|p| f.filename.contains(p.as_str()))
})
.collect()
};
if !self.frames.is_empty() && visible_frames.is_empty() {
let n = self.frames.len();
let msg = format!(
"[suppressed {} frame{}]\n",
n,
if n == 1 { "" } else { "s" }
);
content_parts.push(TextPart::Styled(msg, Style::parse("dim italic")));
let content = Text::assemble(&content_parts, Style::null());
let panel = Panel::new(content).with_title(self.title.clone());
return panel.gilt_console(console, options);
}
let frame_count = visible_frames.len();
let show_count = frame_count.min(self.max_frames);
let truncated = frame_count > self.max_frames;
let frames_to_show: Vec<&Frame> = if truncated {
let half = self.max_frames / 2;
let mut combined: Vec<&Frame> = visible_frames.iter().take(half).copied().collect();
combined.extend(visible_frames.iter().skip(frame_count - half).copied());
combined
} else {
visible_frames.clone()
};
let actual_show = frames_to_show.len();
let half_mark = if truncated {
self.max_frames / 2
} else {
actual_show + 1
};
for (i, frame) in frames_to_show.iter().enumerate() {
if truncated && i == half_mark {
let omitted = frame_count - show_count;
let msg = format!("\n... {} frames omitted ...\n\n", omitted);
content_parts.push(TextPart::Styled(msg, Style::parse("dim italic")));
}
let location = match frame.lineno {
Some(n) => format!("{}:{}", frame.filename, n),
None => frame.filename.clone(),
};
content_parts.push(TextPart::Styled(
format!("File \"{}\"", location),
Style::parse("green"),
));
content_parts.push(TextPart::Styled(
format!(", in {}", frame.name),
Style::parse("magenta"),
));
content_parts.push(TextPart::Raw("\n".to_string()));
#[allow(unused_mut)]
let mut showed_syntax = false;
#[cfg(feature = "syntax")]
if let Some(lineno) = frame.lineno {
if lineno > 0 {
let path = std::path::Path::new(&frame.filename);
if (path.is_absolute() || frame.filename.starts_with("./")) && path.exists() {
if let Ok(file_contents) = std::fs::read_to_string(path) {
let total_lines = file_contents.lines().count();
if lineno <= total_lines {
let start = lineno.saturating_sub(self.extra_lines).max(1);
let end = (lineno + self.extra_lines).min(total_lines);
let context: String = file_contents
.lines()
.enumerate()
.filter(|(i, _)| {
let n = i + 1;
n >= start && n <= end
})
.map(|(_, line)| line)
.collect::<Vec<_>>()
.join("\n");
let ext =
path.extension().and_then(|e| e.to_str()).unwrap_or("txt");
let syntax = Syntax::new(&context, ext)
.with_theme(&self.theme)
.with_line_numbers(true)
.with_start_line(start)
.with_highlight_lines(vec![lineno])
.with_word_wrap(self.word_wrap);
let syntax_segments = syntax.gilt_console(
console,
&options.update_width(panel_width.saturating_sub(4)),
);
if !syntax_segments.is_empty() {
for seg in &syntax_segments {
content_parts.push(TextPart::Raw(seg.text.to_string()));
}
showed_syntax = true;
}
}
}
}
}
}
if !showed_syntax {
if let Some(ref source) = frame.source_line {
let trimmed = source.trim();
if !trimmed.is_empty() {
content_parts.push(TextPart::Raw(format!(" {}\n", trimmed)));
}
}
}
if i + 1 < actual_show {
content_parts.push(TextPart::Raw("\n".to_string()));
}
}
if !self.message.is_empty() {
content_parts.push(TextPart::Raw("\n".to_string()));
content_parts.push(TextPart::Styled(self.message.clone(), Style::parse("bold")));
}
let content_text = Text::assemble(&content_parts, Style::null());
let title_text = if self.title.is_empty() {
Text::styled("Traceback", "bold red")
} else {
Text::styled(&self.title, "bold red")
};
let panel = Panel::new(content_text)
.with_title(title_text)
.with_border_style(Style::parse("red"))
.with_expand(true);
let panel_opts = if let Some(w) = self.width {
options.update_width(w)
} else {
options.clone()
};
panel.gilt_console(console, &panel_opts)
}
}
fn parse_backtrace(bt: &str) -> Vec<Frame> {
static FRAME_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?m)^\s*(\d+):\s+(.+?)$").expect("invalid frame regex"));
static LOCATION_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?m)^\s+at\s+(.+?):(\d+)(?::(\d+))?\s*$").expect("invalid location regex")
});
let frame_re = &*FRAME_RE;
let location_re = &*LOCATION_RE;
let lines: Vec<&str> = bt.lines().collect();
let mut frames = Vec::new();
let mut i = 0;
while i < lines.len() {
let line = lines[i];
if let Some(captures) = frame_re.captures(line) {
let name = captures
.get(2)
.map(|m| m.as_str())
.unwrap_or("")
.trim()
.to_string();
let mut filename = String::new();
let mut lineno = None;
if i + 1 < lines.len() {
if let Some(loc_captures) = location_re.captures(lines[i + 1]) {
filename = loc_captures
.get(1)
.map(|m| m.as_str())
.unwrap_or("")
.to_string();
lineno = loc_captures
.get(2)
.and_then(|m| m.as_str().parse::<usize>().ok());
i += 1; }
}
let mut frame = Frame::new(&filename, lineno, &name);
frame.read_source_line();
frames.push(frame);
}
i += 1;
}
frames
}
fn error_type_name(error: &dyn std::error::Error) -> String {
let debug = format!("{:?}", error);
if let Some(paren) = debug.find('(') {
let brace = debug.find('{').unwrap_or(debug.len());
let end = paren.min(brace);
let name = debug[..end].trim();
if !name.is_empty() && !name.contains(' ') {
return name.to_string();
}
} else if let Some(brace) = debug.find('{') {
let name = debug[..brace].trim();
if !name.is_empty() && !name.contains(' ') {
return name.to_string();
}
}
"Error".to_string()
}
#[cfg(test)]
#[path = "traceback_tests.rs"]
mod tests;