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