Skip to main content

rusty_rich/
traceback.rs

1//! Traceback -- exception traceback rendering. Equivalent to Rich's `traceback.py`.
2//!
3//! Provides data structures for representing tracebacks and a `Traceback`
4//! renderable that displays them with Rich formatting, complete with source
5//! code context, local variable tables, and styled box-drawn borders.
6//!
7//! # Theme keys used
8//!
9//! | Key                        | Style                                      |
10//! |----------------------------|--------------------------------------------|
11//! | `traceback.border`         | border of the outer and inner boxes        |
12//! | `traceback.title`          | "Traceback (most recent call last)" title  |
13//! | `traceback.error`          | exception type  name                       |
14//! | `traceback.error_mark`     | the "❱" marker on the error line           |
15//! | `traceback.filename`       | file paths in frame headers                |
16//! | `traceback.line_no`        | line numbers in source context             |
17//! | `traceback.locals_header`  | header of the locals sub-table             |
18
19use std::collections::HashMap;
20use std::fs;
21use std::path::Path;
22
23use unicode_width::UnicodeWidthStr;
24
25use crate::console::{ConsoleOptions, RenderResult, Renderable};
26use crate::segment::Segment;
27use crate::style::Style;
28use crate::theme;
29
30// ---------------------------------------------------------------------------
31// Data types
32// ---------------------------------------------------------------------------
33
34/// A single frame in a traceback.
35#[derive(Debug, Clone)]
36pub struct Frame {
37    pub filename: String,
38    pub lineno: usize,
39    pub name: String,
40    pub line: Option<String>,
41    pub locals: Option<HashMap<String, String>>,
42    pub last_instruction: Option<String>,
43}
44
45impl Frame {
46    /// Create a new `Frame` with the given filename, line number, and function name.
47    pub fn new(filename: impl Into<String>, lineno: usize, name: impl Into<String>) -> Self {
48        Self {
49            filename: filename.into(),
50            lineno,
51            name: name.into(),
52            line: None,
53            locals: None,
54            last_instruction: None,
55        }
56    }
57
58    /// Builder: attach the source line content for this frame.
59    pub fn line(mut self, line: impl Into<String>) -> Self {
60        self.line = Some(line.into());
61        self
62    }
63
64    /// Builder: attach local variables.
65    pub fn locals(mut self, locals: HashMap<String, String>) -> Self {
66        self.locals = Some(locals);
67        self
68    }
69}
70
71/// A stack of frames (one exception level).
72#[derive(Debug, Clone, Default)]
73pub struct Stack {
74    pub exc_type: Option<String>,
75    pub exc_value: Option<String>,
76    pub syntax_error: Option<String>,
77    pub is_cause: bool,
78    pub frames: Vec<Frame>,
79    pub notes: Vec<String>,
80    pub is_group: bool,
81    pub exceptions: Vec<Stack>,
82}
83
84impl Stack {
85    /// Create a new empty `Stack` with no exception type, value, or frames.
86    pub fn new() -> Self {
87        Self {
88            exc_type: None,
89            exc_value: None,
90            syntax_error: None,
91            is_cause: false,
92            frames: Vec::new(),
93            notes: Vec::new(),
94            is_group: false,
95            exceptions: Vec::new(),
96        }
97    }
98
99    /// Builder: set the exception type name (e.g. `"ValueError"`).
100    pub fn exc_type(mut self, t: impl Into<String>) -> Self {
101        self.exc_type = Some(t.into());
102        self
103    }
104
105    /// Builder: set the exception message/value.
106    pub fn exc_value(mut self, v: impl Into<String>) -> Self {
107        self.exc_value = Some(v.into());
108        self
109    }
110
111    /// Builder: append a [`Frame`] to the stack's frame list.
112    pub fn add_frame(mut self, frame: Frame) -> Self {
113        self.frames.push(frame);
114        self
115    }
116}
117
118/// Full trace data.
119#[derive(Debug, Clone, Default)]
120pub struct Trace {
121    pub stacks: Vec<Stack>,
122}
123
124impl Trace {
125    /// Create a new empty `Trace` with no stacks.
126    pub fn new() -> Self {
127        Self { stacks: Vec::new() }
128    }
129
130    /// Create a `Trace` containing a single [`Stack`].
131    pub fn from_stack(stack: Stack) -> Self {
132        Self {
133            stacks: vec![stack],
134        }
135    }
136}
137
138// ---------------------------------------------------------------------------
139// Traceback -- renderable
140// ---------------------------------------------------------------------------
141
142/// Renders a traceback with Rich formatting.
143///
144/// Mimics Python Rich's `rich.traceback.Traceback` renderable.
145#[derive(Debug, Clone)]
146pub struct Traceback {
147    trace: Trace,
148    width: Option<usize>,
149    code_width: Option<usize>,
150    extra_lines: usize,
151    theme_name: Option<String>,
152    word_wrap: bool,
153    show_locals: bool,
154    indent_guides: bool,
155    locals_max_length: usize,
156    locals_max_string: usize,
157    locals_max_depth: usize,
158    locals_hide_dunder: bool,
159    locals_hide_sunder: bool,
160    suppress: Vec<String>,
161    max_frames: Option<usize>,
162}
163
164impl Traceback {
165    /// Create a new Traceback from `Trace` data.
166    pub fn new(trace: Trace) -> Self {
167        Self {
168            trace,
169            width: None,
170            code_width: None,
171            extra_lines: 3,
172            theme_name: None,
173            word_wrap: false,
174            show_locals: false,
175            indent_guides: false,
176            locals_max_length: 10,
177            locals_max_string: 80,
178            locals_max_depth: 5,
179            locals_hide_dunder: true,
180            locals_hide_sunder: false,
181            suppress: Vec::new(),
182            max_frames: None,
183        }
184    }
185
186    /// Convenience constructor: build a `Traceback` from an exception type,
187    /// value, and list of frames.
188    pub fn from_exception(
189        exc_type: impl Into<String>,
190        exc_value: impl Into<String>,
191        frames: Vec<Frame>,
192    ) -> Self {
193        let mut stack = Stack::new();
194        stack.exc_type = Some(exc_type.into());
195        stack.exc_value = Some(exc_value.into());
196        stack.frames = frames;
197        let trace = Trace::from_stack(stack);
198        Self::new(trace)
199    }
200
201    // -- Builder methods --------------------------------------------------
202
203    /// Builder: set the total output width in characters.
204    pub fn width(mut self, width: usize) -> Self {
205        self.width = Some(width);
206        self
207    }
208
209    /// Builder: set the width reserved for source code (excluding line numbers).
210    pub fn code_width(mut self, width: usize) -> Self {
211        self.code_width = Some(width);
212        self
213    }
214
215    /// Builder: set the number of extra source lines shown before and after the error line.
216    pub fn extra_lines(mut self, n: usize) -> Self {
217        self.extra_lines = n;
218        self
219    }
220
221    /// Builder: set the theme name (e.g. `"monokai"`, `"base16-ocean.dark"`).
222    pub fn theme(mut self, theme: impl Into<String>) -> Self {
223        self.theme_name = Some(theme.into());
224        self
225    }
226
227    /// Builder: enable or disable word wrapping of long lines.
228    pub fn word_wrap(mut self, wrap: bool) -> Self {
229        self.word_wrap = wrap;
230        self
231    }
232
233    /// Builder: show local variables at each frame when set to `true`.
234    pub fn show_locals(mut self, show: bool) -> Self {
235        self.show_locals = show;
236        self
237    }
238
239    /// Builder: enable indentation guides in source context.
240    pub fn indent_guides(mut self, guides: bool) -> Self {
241        self.indent_guides = guides;
242        self
243    }
244
245    /// Builder: set the maximum number of local variables to display per frame.
246    pub fn locals_max_length(mut self, n: usize) -> Self {
247        self.locals_max_length = n;
248        self
249    }
250
251    /// Builder: set the maximum length for local variable string values.
252    pub fn locals_max_string(mut self, n: usize) -> Self {
253        self.locals_max_string = n;
254        self
255    }
256
257    /// Builder: set the maximum depth for nested local variable display.
258    pub fn locals_max_depth(mut self, n: usize) -> Self {
259        self.locals_max_depth = n;
260        self
261    }
262
263    /// Builder: hide locals with dunder names (e.g. `__name__`) when `true`.
264    pub fn locals_hide_dunder(mut self, hide: bool) -> Self {
265        self.locals_hide_dunder = hide;
266        self
267    }
268
269    /// Builder: hide locals with underscore-prefixed names (e.g. `_secret`) when `true`.
270    pub fn locals_hide_sunder(mut self, hide: bool) -> Self {
271        self.locals_hide_sunder = hide;
272        self
273    }
274
275    /// Builder: suppress frames whose filename matches any of the given patterns.
276    pub fn suppress(mut self, suppress: Vec<String>) -> Self {
277        self.suppress = suppress;
278        self
279    }
280
281    /// Builder: limit the number of frames shown (remaining are collapsed into a single message).
282    pub fn max_frames(mut self, n: usize) -> Self {
283        self.max_frames = Some(n);
284        self
285    }
286}
287
288// ---------------------------------------------------------------------------
289// Style helpers -- resolve theme styles from the default theme
290// ---------------------------------------------------------------------------
291
292/// Look up a style from the default theme, returning a default-constructed
293/// Style if the key is not present.
294fn theme_style(name: &str) -> Style {
295    crate::theme::default_theme()
296        .get(name)
297        .cloned()
298        .unwrap_or_default()
299}
300
301// ---------------------------------------------------------------------------
302// Rendering helpers
303// ---------------------------------------------------------------------------
304
305/// Build an outer content line: "│ " + content + " │", padded to `width`.
306fn outer_content_line(content: Vec<Segment>, total_width: usize) -> Vec<Segment> {
307    let border_style = theme_style(theme::names::TRACEBACK_BORDER);
308    let mut line = Vec::new();
309
310    // Left border
311    line.push(Segment::styled("│ ".to_string(), border_style.clone()));
312
313    // Content
314    let mut content_w = 0usize;
315    for seg in &content {
316        content_w += seg.cell_length();
317    }
318    line.extend(content);
319
320    // Right padding
321    let inner_w = total_width.saturating_sub(4); // "│ " + " │"
322    let pad = inner_w.saturating_sub(content_w);
323    if pad > 0 {
324        line.push(Segment::new(" ".repeat(pad)));
325    }
326
327    // Right border
328    line.push(Segment::styled(" │".to_string(), border_style));
329    line
330}
331
332/// Build a blank content line (empty line with just the outer borders).
333fn outer_blank(total_width: usize) -> Vec<Segment> {
334    outer_content_line(Vec::new(), total_width)
335}
336
337/// Build the outer top border with the traceback title.
338fn top_border(total_width: usize) -> Vec<Segment> {
339    let border_style = theme_style(theme::names::TRACEBACK_BORDER);
340    let title_style = theme_style(theme::names::TRACEBACK_TITLE);
341
342    let title = " Traceback (most recent call last) ";
343    let dashes_total = total_width.saturating_sub(title.len() + 4); // ╭ ─╮
344    let left_dashes = dashes_total / 2;
345    let right_dashes = dashes_total - left_dashes;
346
347    vec![
348        Segment::styled("╭─".to_string(), border_style.clone()),
349        Segment::styled(
350            "─".repeat(left_dashes.saturating_sub(1)),
351            border_style.clone(),
352        ),
353        Segment::styled(title.to_string(), title_style),
354        Segment::styled(
355            "─".repeat(right_dashes.saturating_sub(1)),
356            border_style.clone(),
357        ),
358        Segment::styled("─╮".to_string(), border_style),
359    ]
360}
361
362/// Build the outer bottom border.
363fn bottom_border(total_width: usize) -> Vec<Segment> {
364    let border_style = theme_style(theme::names::TRACEBACK_BORDER);
365    let dashes = total_width.saturating_sub(2);
366    vec![Segment::styled(
367        format!("╰{}╯", "─".repeat(dashes)),
368        border_style,
369    )]
370}
371
372/// Helper: read source file lines around a given line number.
373fn read_source_lines(
374    filename: &str,
375    lineno: usize,
376    extra_lines: usize,
377) -> (usize, Vec<(usize, String)>) {
378    // Try to open the file
379    let content = match fs::read_to_string(Path::new(filename)) {
380        Ok(s) => s,
381        Err(_) => return (0, Vec::new()),
382    };
383
384    let all_lines: Vec<&str> = content.lines().collect();
385    if all_lines.is_empty() {
386        return (0, Vec::new());
387    }
388
389    let start = if lineno > extra_lines {
390        lineno - extra_lines
391    } else {
392        1
393    };
394    // lineno is 1-based, all_lines is 0-based
395    let end = (lineno + extra_lines).min(all_lines.len());
396
397    let mut result = Vec::new();
398    for i in start..=end {
399        let line_str = all_lines.get(i.saturating_sub(1)).copied().unwrap_or("");
400        result.push((i, line_str.to_string()));
401    }
402
403    (lineno, result)
404}
405
406/// Check whether a filename matches any of the suppress patterns.
407fn is_suppressed(filename: &str, suppress: &[String]) -> bool {
408    for pattern in suppress {
409        if filename.starts_with(pattern) || filename.contains(pattern) {
410            return true;
411        }
412    }
413    false
414}
415
416// ---------------------------------------------------------------------------
417// Renderable implementation
418// ---------------------------------------------------------------------------
419
420impl Renderable for Traceback {
421    fn render(&self, options: &ConsoleOptions) -> RenderResult {
422        let total_width = self.width.unwrap_or(options.max_width.min(120));
423        let content_width = total_width.saturating_sub(4); // space for "│ " + " │"
424
425        // Resolve styles
426        let border_style = theme_style(theme::names::TRACEBACK_BORDER);
427        let filename_style = theme_style(theme::names::TRACEBACK_FILENAME);
428        let line_no_style = theme_style(theme::names::TRACEBACK_LINE_NO);
429        let error_mark_style = theme_style(theme::names::TRACEBACK_ERROR_MARK);
430        let error_style = theme_style(theme::names::TRACEBACK_ERROR);
431        let locals_header_style = theme_style(theme::names::TRACEBACK_LOCALS_HEADER);
432
433        // Collect all output lines (as full-width segments, including "│ " / " │" borders)
434        let mut out_lines: Vec<Vec<Segment>> = Vec::new();
435
436        // Top border
437        out_lines.push(top_border(total_width));
438
439        // Blank line after top border
440        out_lines.push(outer_blank(total_width));
441
442        // Track how many frames we've rendered, for max_frames / suppression
443        let mut rendered_count = 0usize;
444        let mut suppressed_count = 0usize;
445
446        // Iterate over stacks
447        for stack in &self.trace.stacks {
448            // Iterate over frames (oldest call first, most recent last)
449            let frames_iter: Box<dyn Iterator<Item = &Frame>> = if stack.is_cause {
450                // For chained exceptions, show frames in order
451                Box::new(stack.frames.iter())
452            } else {
453                Box::new(stack.frames.iter())
454            };
455
456            let max_frames = self.max_frames.unwrap_or(usize::MAX);
457
458            for frame in frames_iter {
459                // Check suppression
460                if is_suppressed(&frame.filename, &self.suppress) {
461                    suppressed_count += 1;
462                    continue;
463                }
464
465                // Check max_frames
466                if rendered_count >= max_frames {
467                    suppressed_count += 1;
468                    continue;
469                }
470                rendered_count += 1;
471
472                // --- Frame location header ---
473                // "  /path/to/file.rs:42 in function_name"
474                {
475                    let loc = format!("{}:{}", frame.filename, frame.lineno);
476                    let func = if frame.name.is_empty() {
477                        String::new()
478                    } else {
479                        format!(" in {}", frame.name)
480                    };
481
482                    let mut header_segs = Vec::new();
483                    header_segs.push(Segment::styled(
484                        format!("  {}", loc),
485                        filename_style.clone(),
486                    ));
487                    header_segs.push(Segment::styled(func, Style::new()));
488                    out_lines.push(outer_content_line(header_segs, total_width));
489                }
490
491                // --- Source code context (read from file) ---
492                let (error_line_num, source_lines) =
493                    read_source_lines(&frame.filename, frame.lineno, self.extra_lines);
494
495                if !source_lines.is_empty() {
496                    // Build the source sub-box
497                    let indent = 2usize;
498                    let sub_box_total = content_width.saturating_sub(indent * 2);
499                    let sub_box_inner = sub_box_total.saturating_sub(2); // exclude "│" borders
500
501                    // Determine line number width
502                    // (max line number in the context)
503                    let max_ln = source_lines.iter().map(|(ln, _)| *ln).max().unwrap_or(0);
504                    let ln_width = max_ln.to_string().len().max(2);
505
506                    // Marker character width (❱ is 2 cells wide in Unicode)
507                    let marker_cells = 2;
508
509                    // Prefix: "❱ " or "  " + padded_line_no + " │ "
510                    // Actually for the line content: marker + " " + line_no + " │ " + code
511                    // marker is "❱ " (2 cells) for error line, "  " (2 cells) for normal
512                    let prefix_cells = marker_cells + 1 + ln_width + 3; // marker *1 + space + ln + " │ "
513                    let code_cells = sub_box_inner.saturating_sub(prefix_cells);
514
515                    // Sub-box top border
516                    {
517                        let mut segs = Vec::new();
518                        segs.push(Segment::styled(
519                            format!("{}╭{}╮", " ".repeat(indent), "─".repeat(sub_box_inner)),
520                            border_style.clone(),
521                        ));
522                        out_lines.push(outer_content_line(segs, total_width));
523                    }
524
525                    // Source lines
526                    for (line_num, line_text) in &source_lines {
527                        let is_error = *line_num == error_line_num;
528
529                        let marker = if is_error { "❱" } else { " " };
530                        let marker_str = format!("{:<width$}", marker, width = marker_cells);
531
532                        let ln_str = format!("{:>width$}", line_num, width = ln_width);
533                        let code = truncate_to_width(line_text, code_cells);
534
535                        let raw_line = format!("{}{} {} │ {} ", marker_str, " ", ln_str, code);
536
537                        // Now build: indent + "│" + raw_line + "│"
538                        // The raw_line should be padded to sub_box_inner - 2
539                        let inner_w = sub_box_inner.saturating_sub(2); // for │ │
540                        let raw_width = UnicodeWidthStr::width(raw_line.as_str());
541                        let pad_w = inner_w.saturating_sub(raw_width);
542                        let _padded = if pad_w > 0 {
543                            format!("{}{}", raw_line, " ".repeat(pad_w))
544                        } else {
545                            raw_line
546                        };
547
548                        // Style the segments
549                        let mut segs = Vec::new();
550
551                        // Indent (no style)
552                        segs.push(Segment::new(" ".repeat(indent)));
553
554                        // Left sub-box border
555                        segs.push(Segment::styled("│".to_string(), border_style.clone()));
556
557                        // Marker
558                        if is_error {
559                            segs.push(Segment::styled(
560                                marker_str.to_string(),
561                                error_mark_style.clone(),
562                            ));
563                        } else {
564                            segs.push(Segment::new(marker_str));
565                        }
566
567                        // Space + line number
568                        let ln_part = format!(" {} ", ln_str);
569                        segs.push(Segment::styled(ln_part, line_no_style.clone()));
570
571                        // " │ "
572                        segs.push(Segment::styled(" │ ", border_style.clone()));
573
574                        // Code
575                        segs.push(Segment::new(code.to_string()));
576
577                        // Padding
578                        // Count width so far after the "│" marker
579                        let after_marker_w =
580                            marker_cells + 1 + ln_width + 3 + UnicodeWidthStr::width(code.as_str());
581                        let remain = sub_box_inner
582                            .saturating_sub(2) // for │ │
583                            .saturating_sub(after_marker_w);
584                        if remain > 0 {
585                            segs.push(Segment::new(" ".repeat(remain)));
586                        }
587
588                        // Right sub-box border
589                        segs.push(Segment::styled("│".to_string(), border_style.clone()));
590
591                        out_lines.push(outer_content_line(segs, total_width));
592                    }
593
594                    // Sub-box bottom border
595                    {
596                        let mut segs = Vec::new();
597                        segs.push(Segment::styled(
598                            format!("{}╰{}╯", " ".repeat(indent), "─".repeat(sub_box_inner)),
599                            border_style.clone(),
600                        ));
601                        out_lines.push(outer_content_line(segs, total_width));
602                    }
603                } else if let Some(ref line_text) = frame.line {
604                    // No source file found -- render the stored line as plain text
605                    let indent = 2usize;
606                    let mut segs = Vec::new();
607                    segs.push(Segment::new(format!(
608                        "{}❱ {}",
609                        " ".repeat(indent),
610                        line_text
611                    )));
612                    out_lines.push(outer_content_line(segs, total_width));
613                }
614
615                // --- Locals table (if enabled and available) ---
616                if self.show_locals {
617                    if let Some(ref locals) = frame.locals {
618                        if !locals.is_empty() {
619                            // Locals sub-box
620                            let indent = 2usize;
621                            let sub_box_total = content_width.saturating_sub(indent * 2);
622                            let sub_box_inner = sub_box_total.saturating_sub(2);
623
624                            // Build locals header
625                            let header_text = " locals ";
626
627                            // Top border of locals sub-box with header
628                            {
629                                let mut segs = Vec::new();
630                                segs.push(Segment::styled(
631                                    format!("{}╭─", " ".repeat(indent)),
632                                    border_style.clone(),
633                                ));
634                                segs.push(Segment::styled(
635                                    header_text.to_string(),
636                                    locals_header_style.clone(),
637                                ));
638                                let dash_count =
639                                    sub_box_inner.saturating_sub(header_text.len() + 1);
640                                segs.push(Segment::styled(
641                                    format!("─{}╮", "─".repeat(dash_count)),
642                                    border_style.clone(),
643                                ));
644                                out_lines.push(outer_content_line(segs, total_width));
645                            }
646
647                            // Local variable entries
648                            let inner_w = sub_box_inner.saturating_sub(2); // │ │
649                            let max_shown = self.locals_max_length;
650                            let filtered_locals: Vec<(&String, &String)> = locals
651                                .iter()
652                                .filter(|(k, _)| {
653                                    if self.locals_hide_dunder
654                                        && k.starts_with("__")
655                                        && k.ends_with("__")
656                                    {
657                                        return false;
658                                    }
659                                    if self.locals_hide_sunder && k.starts_with('_') {
660                                        return false;
661                                    }
662                                    true
663                                })
664                                .take(max_shown)
665                                .collect();
666
667                            for (key, val) in &filtered_locals {
668                                let max_str_len = self.locals_max_string;
669                                let display_val = if val.len() > max_str_len {
670                                    format!("{}...", &val[..max_str_len])
671                                } else {
672                                    val.to_string()
673                                };
674                                let line_text = format!("{} = {}", key, display_val);
675                                let raw_w = UnicodeWidthStr::width(line_text.as_str());
676                                let pad_w = inner_w.saturating_sub(raw_w);
677                                let padded = if pad_w > 0 {
678                                    format!("{}{}", line_text, " ".repeat(pad_w))
679                                } else {
680                                    truncate_to_width(&line_text, inner_w)
681                                };
682
683                                let mut segs = Vec::new();
684                                segs.push(Segment::new(" ".repeat(indent)));
685                                segs.push(Segment::styled("│".to_string(), border_style.clone()));
686                                segs.push(Segment::new(format!(" {}", padded)));
687                                // Add padding
688                                let extra_pad =
689                                    inner_w.saturating_sub(UnicodeWidthStr::width(padded.as_str()));
690                                if extra_pad > 0 {
691                                    segs.push(Segment::new(" ".repeat(extra_pad)));
692                                }
693                                segs.push(Segment::styled(" │".to_string(), border_style.clone()));
694                                out_lines.push(outer_content_line(segs, total_width));
695                            }
696
697                            // Bottom border of locals sub-box
698                            {
699                                let mut segs = Vec::new();
700                                segs.push(Segment::styled(
701                                    format!(
702                                        "{}╰{}╯",
703                                        " ".repeat(indent),
704                                        "─".repeat(sub_box_inner),
705                                    ),
706                                    border_style.clone(),
707                                ));
708                                out_lines.push(outer_content_line(segs, total_width));
709                            }
710                        }
711                    }
712                }
713
714                // Blank line after frame
715                out_lines.push(outer_blank(total_width));
716            }
717
718            // Show suppressed frame count
719            if suppressed_count > 0 {
720                let msg = format!("  ... {} frames hidden ...", suppressed_count);
721                let segs = vec![Segment::styled(msg, Style::new().dim(true))];
722                out_lines.push(outer_content_line(segs, total_width));
723                out_lines.push(outer_blank(total_width));
724                suppressed_count = 0;
725            }
726
727            // --- Exception type and value ---
728            if let Some(ref exc_type) = stack.exc_type {
729                let exc_value = stack.exc_value.as_deref().unwrap_or("");
730                let msg = if exc_value.is_empty() {
731                    format!("  {}", exc_type)
732                } else {
733                    format!("  {}: {}", exc_type, exc_value)
734                };
735                let segs = vec![Segment::styled(msg, error_style.clone())];
736                out_lines.push(outer_content_line(segs, total_width));
737                out_lines.push(outer_blank(total_width));
738            }
739
740            // Exception notes
741            for note in &stack.notes {
742                let mut segs = Vec::new();
743                segs.push(Segment::styled(
744                    format!("  note: {}", note),
745                    Style::new().italic(true),
746                ));
747                out_lines.push(outer_content_line(segs, total_width));
748            }
749        }
750
751        // Bottom border
752        out_lines.push(bottom_border(total_width));
753
754        RenderResult {
755            lines: out_lines,
756            items: Vec::new(),
757        }
758    }
759}
760
761// ---------------------------------------------------------------------------
762// Utility: truncate a string to a given visible (Unicode) width
763// ---------------------------------------------------------------------------
764
765fn truncate_to_width(s: &str, max_width: usize) -> String {
766    if max_width == 0 {
767        return String::new();
768    }
769    let mut w = 0usize;
770    let mut result = String::new();
771    for ch in s.chars() {
772        let cw = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
773        if w + cw > max_width {
774            break;
775        }
776        w += cw;
777        result.push(ch);
778    }
779    result
780}
781
782// ---------------------------------------------------------------------------
783// Global install -- panic hook
784// ---------------------------------------------------------------------------
785
786/// Install a panic hook that renders Rich-formatted tracebacks to stderr.
787///
788/// This is a best-effort hook -- it attempts to capture the panic payload and
789/// produce a formatted traceback, but may not capture full source context.
790pub fn install() {
791    std::panic::set_hook(Box::new(|panic_info| {
792        use std::io::Write;
793
794        // Extract panic message
795        let msg = if let Some(s) = panic_info.payload().downcast_ref::<&str>() {
796            s.to_string()
797        } else if let Some(s) = panic_info.payload().downcast_ref::<String>() {
798            s.clone()
799        } else {
800            "unknown panic".to_string()
801        };
802
803        // Extract location
804        let (file, line, col) = if let Some(loc) = panic_info.location() {
805            (
806                loc.file().to_string(),
807                loc.line() as usize,
808                loc.column() as usize,
809            )
810        } else {
811            ("unknown".to_string(), 0, 0)
812        };
813
814        // Build a manual traceback using the backtrace crate (if available) or
815        // a simple frame using the panic location.
816        let mut frame = Frame::new(file.clone(), line, "unknown".to_string());
817        frame.line = Some(msg.clone());
818
819        let exc_value = format!("panic at {}:{}:{}", file, line, col);
820        let traceback = Traceback::from_exception("Panic", exc_value, vec![frame]).extra_lines(0);
821
822        // Render to segments
823        let opts = ConsoleOptions {
824            max_width: 120,
825            ..ConsoleOptions::default()
826        };
827        let result = traceback.render(&opts);
828        let ansi = result.to_ansi();
829
830        let _ = writeln!(std::io::stderr(), "{}", ansi);
831    }));
832}
833
834// ---------------------------------------------------------------------------
835// Tests
836// ---------------------------------------------------------------------------
837
838#[cfg(test)]
839mod tests {
840    use super::*;
841
842    #[test]
843    fn test_frame_new() {
844        let f = Frame::new("main.rs", 42, "foo");
845        assert_eq!(f.filename, "main.rs");
846        assert_eq!(f.lineno, 42);
847        assert_eq!(f.name, "foo");
848        assert!(f.line.is_none());
849        assert!(f.locals.is_none());
850    }
851
852    #[test]
853    fn test_frame_builder() {
854        let mut locals = HashMap::new();
855        locals.insert("x".to_string(), "42".to_string());
856
857        let f = Frame::new("lib.rs", 10, "bar")
858            .line("let x = 42;")
859            .locals(locals.clone());
860
861        assert_eq!(f.line.unwrap(), "let x = 42;");
862        assert_eq!(f.locals.unwrap()["x"], "42");
863    }
864
865    #[test]
866    fn test_stack_new() {
867        let s = Stack::new();
868        assert!(s.exc_type.is_none());
869        assert!(s.exc_value.is_none());
870        assert!(!s.is_cause);
871        assert!(s.frames.is_empty());
872    }
873
874    #[test]
875    fn test_stack_builder() {
876        let s = Stack::new()
877            .exc_type("ValueError")
878            .exc_value("bad value")
879            .add_frame(Frame::new("test.rs", 5, "broken"));
880
881        assert_eq!(s.exc_type.unwrap(), "ValueError");
882        assert_eq!(s.exc_value.unwrap(), "bad value");
883        assert_eq!(s.frames.len(), 1);
884    }
885
886    #[test]
887    fn test_trace_new() {
888        let t = Trace::new();
889        assert!(t.stacks.is_empty());
890    }
891
892    #[test]
893    fn test_trace_from_stack() {
894        let s = Stack::new();
895        let t = Trace::from_stack(s);
896        assert_eq!(t.stacks.len(), 1);
897    }
898
899    #[test]
900    fn test_traceback_from_exception() {
901        let tb = Traceback::from_exception(
902            "Error",
903            "something went wrong",
904            vec![
905                Frame::new("main.rs", 1, "main"),
906                Frame::new("lib.rs", 42, "helper"),
907            ],
908        );
909        assert_eq!(tb.trace.stacks.len(), 1);
910        let stack = &tb.trace.stacks[0];
911        assert_eq!(stack.exc_type.as_deref(), Some("Error"));
912        assert_eq!(stack.exc_value.as_deref(), Some("something went wrong"));
913        assert_eq!(stack.frames.len(), 2);
914    }
915
916    #[test]
917    fn test_traceback_builder_methods() {
918        let tb = Traceback::new(Trace::new())
919            .width(100)
920            .code_width(80)
921            .extra_lines(5)
922            .theme("monokai")
923            .word_wrap(true)
924            .show_locals(true)
925            .indent_guides(true)
926            .locals_max_length(20)
927            .locals_max_string(120)
928            .locals_max_depth(10)
929            .locals_hide_dunder(false)
930            .locals_hide_sunder(true)
931            .suppress(vec!["std".to_string()])
932            .max_frames(10);
933
934        assert_eq!(tb.width, Some(100));
935        assert_eq!(tb.code_width, Some(80));
936        assert_eq!(tb.extra_lines, 5);
937        assert!(tb.word_wrap);
938        assert!(tb.show_locals);
939        assert!(!tb.locals_hide_dunder);
940        assert!(tb.locals_hide_sunder);
941    }
942
943    #[test]
944    fn test_truncate_to_width() {
945        assert_eq!(truncate_to_width("hello", 3), "hel");
946        assert_eq!(truncate_to_width("hi", 10), "hi");
947        assert_eq!(truncate_to_width("", 5), "");
948        assert_eq!(truncate_to_width("hello", 0), "");
949    }
950
951    #[test]
952    fn test_is_suppressed() {
953        let suppress = vec!["std".to_string(), "core".to_string()];
954        assert!(is_suppressed(
955            "/rustc/.../library/std/src/panic.rs",
956            &suppress,
957        ));
958        assert!(is_suppressed(
959            "/rustc/.../library/core/src/result.rs",
960            &suppress,
961        ));
962        assert!(!is_suppressed("/home/user/project/src/main.rs", &suppress,));
963    }
964
965    #[test]
966    fn test_render_empty_traceback() {
967        let tb = Traceback::new(Trace::new()).width(60);
968        let opts = ConsoleOptions {
969            max_width: 60,
970            ..ConsoleOptions::default()
971        };
972        let result = tb.render(&opts);
973        // Should have at least top and bottom borders
974        assert!(!result.lines.is_empty());
975        // Top border should contain the title
976        let ansi = result.to_ansi();
977        assert!(ansi.contains("Traceback"));
978        assert!(ansi.contains("╭"));
979        assert!(ansi.contains("╰"));
980    }
981
982    #[test]
983    fn test_render_single_frame() {
984        let tb = Traceback::from_exception(
985            "TestError",
986            "testing",
987            vec![Frame::new("fake.rs", 10, "test_fn")],
988        )
989        .width(80);
990        let opts = ConsoleOptions {
991            max_width: 80,
992            ..ConsoleOptions::default()
993        };
994        let result = tb.render(&opts);
995        let ansi = result.to_ansi();
996        assert!(ansi.contains("Traceback"));
997        assert!(ansi.contains("TestError"));
998        assert!(ansi.contains("testing"));
999        assert!(ansi.contains("fake.rs"));
1000    }
1001
1002    #[test]
1003    fn test_render_with_locals() {
1004        let mut locals = HashMap::new();
1005        locals.insert("x".to_string(), "42".to_string());
1006        locals.insert("name".to_string(), "hello".to_string());
1007
1008        let tb = Traceback::from_exception(
1009            "Error",
1010            "msg",
1011            vec![Frame::new("test.rs", 5, "func").locals(locals)],
1012        )
1013        .width(80)
1014        .show_locals(true);
1015
1016        let opts = ConsoleOptions {
1017            max_width: 80,
1018            ..ConsoleOptions::default()
1019        };
1020        let result = tb.render(&opts);
1021        let ansi = result.to_ansi();
1022        // Should include locals (variable names)
1023        assert!(ansi.contains("x") || ansi.contains("name"));
1024    }
1025
1026    #[test]
1027    fn test_render_suppressed_frame() {
1028        let tb = Traceback::from_exception(
1029            "Err",
1030            "msg",
1031            vec![
1032                Frame::new("/rustc/lib.rs", 1, "hidden_fn"),
1033                Frame::new("main.rs", 10, "main"),
1034            ],
1035        )
1036        .width(80)
1037        .suppress(vec!["/rustc".to_string()]);
1038
1039        let opts = ConsoleOptions {
1040            max_width: 80,
1041            ..ConsoleOptions::default()
1042        };
1043        let result = tb.render(&opts);
1044        let ansi = result.to_ansi();
1045        assert!(ansi.contains("1 frames hidden") || ansi.contains("frames hidden"));
1046        assert!(ansi.contains("main.rs"));
1047    }
1048
1049    #[test]
1050    fn test_max_frames() {
1051        let tb = Traceback::from_exception(
1052            "Err",
1053            "msg",
1054            vec![
1055                Frame::new("a.rs", 1, "a"),
1056                Frame::new("b.rs", 2, "b"),
1057                Frame::new("c.rs", 3, "c"),
1058            ],
1059        )
1060        .width(80)
1061        .max_frames(2);
1062
1063        let opts = ConsoleOptions {
1064            max_width: 80,
1065            ..ConsoleOptions::default()
1066        };
1067        let result = tb.render(&opts);
1068        let ansi = result.to_ansi();
1069        // Should mention hidden frames
1070        assert!(ansi.contains("frames hidden") || ansi.contains("hidden"));
1071    }
1072
1073    #[test]
1074    fn test_theme_style_resolution() {
1075        let style = theme_style(theme::names::TRACEBACK_BORDER);
1076        // Should return a non-plain style (has color or attributes)
1077        assert!(!style.is_plain());
1078    }
1079
1080    #[test]
1081    fn test_locals_filtering_dunder() {
1082        let mut locals = HashMap::new();
1083        locals.insert("__private__".to_string(), "secret".to_string());
1084        locals.insert("normal".to_string(), "visible".to_string());
1085
1086        let tb =
1087            Traceback::from_exception("E", "msg", vec![Frame::new("t.rs", 1, "f").locals(locals)])
1088                .width(80)
1089                .show_locals(true)
1090                .locals_hide_dunder(true);
1091
1092        let opts = ConsoleOptions {
1093            max_width: 80,
1094            ..ConsoleOptions::default()
1095        };
1096        let result = tb.render(&opts);
1097        let ansi = result.to_ansi();
1098
1099        // dunder vars default to hidden
1100        let _has_private = ansi.contains("__private__");
1101        let has_normal = ansi.contains("normal");
1102
1103        // The normal variable should appear; the dunder may or may not
1104        // (the filtering is applied and should suppress dunder)
1105        assert!(has_normal);
1106    }
1107
1108    #[test]
1109    fn test_locals_filtering_sunder() {
1110        let mut locals = HashMap::new();
1111        locals.insert("_hidden".to_string(), "invisible".to_string());
1112        locals.insert("visible".to_string(), "yes".to_string());
1113
1114        let tb =
1115            Traceback::from_exception("E", "msg", vec![Frame::new("t.rs", 1, "f").locals(locals)])
1116                .width(80)
1117                .show_locals(true)
1118                .locals_hide_sunder(true);
1119
1120        let opts = ConsoleOptions {
1121            max_width: 80,
1122            ..ConsoleOptions::default()
1123        };
1124        let result = tb.render(&opts);
1125        let ansi = result.to_ansi();
1126
1127        // sunder vars should be hidden
1128        assert!(!ansi.contains("_hidden"));
1129        assert!(ansi.contains("visible"));
1130    }
1131
1132    #[test]
1133    fn test_install_hook() {
1134        // Just verify that install() does not panic
1135        install();
1136        // Reset the hook so it doesn't interfere with other tests
1137        let _ = std::panic::take_hook();
1138    }
1139
1140    #[test]
1141    fn test_multiple_stacks() {
1142        let mut stack1 = Stack::new();
1143        stack1.exc_type = Some("IOError".to_string());
1144        stack1.exc_value = Some("file not found".to_string());
1145        stack1.frames.push(Frame::new("io.rs", 10, "read_file"));
1146
1147        let mut stack2 = Stack::new();
1148        stack2.exc_type = Some("ValueError".to_string());
1149        stack2.exc_value = Some("bad data".to_string());
1150        stack2.is_cause = true;
1151        stack2.frames.push(Frame::new("main.rs", 20, "process"));
1152
1153        let trace = Trace {
1154            stacks: vec![stack1, stack2],
1155        };
1156
1157        let tb = Traceback::new(trace).width(80);
1158        let opts = ConsoleOptions {
1159            max_width: 80,
1160            ..ConsoleOptions::default()
1161        };
1162        let result = tb.render(&opts);
1163        let ansi = result.to_ansi();
1164
1165        assert!(ansi.contains("IOError"));
1166        assert!(ansi.contains("ValueError"));
1167    }
1168}