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