Skip to main content

rich_rs/
traceback.rs

1//! Traceback: structured exception/error display.
2//!
3//! This module provides data structures for representing stack traces and
4//! exceptions. It's designed to work with the `Syntax` module for code
5//! highlighting and `scope` module for local variable display.
6//!
7//! # Example
8//!
9//! ```
10//! use rich_rs::traceback::{Frame, Stack, Trace, Traceback};
11//!
12//! // Create a simple stack trace
13//! let frame = Frame::new("main.rs", 42, "main");
14//! let stack = Stack::new("RuntimeError", "Something went wrong")
15//!     .with_frame(frame);
16//! let trace = Trace::new(vec![stack]);
17//!
18//! let tb = Traceback::new(trace);
19//! ```
20
21use std::collections::BTreeMap;
22use std::fs;
23use std::io::Stdout;
24use std::path::Path;
25
26use crate::Renderable;
27use crate::r#box::ROUNDED;
28use crate::console::{Console, ConsoleOptions};
29use crate::highlighter::{Highlighter, RegexHighlighter, path_highlighter, repr_highlighter};
30use crate::measure::Measurement;
31use crate::scope::render_scope;
32use crate::segment::{Segment, Segments};
33use crate::style::Style;
34use crate::syntax::Syntax;
35use crate::text::Text;
36
37// ============================================================================
38// Constants
39// ============================================================================
40
41/// Default maximum number of frames to display.
42pub const DEFAULT_MAX_FRAMES: usize = 100;
43
44/// Default number of extra context lines around the error line.
45pub const DEFAULT_EXTRA_LINES: usize = 3;
46
47/// Default maximum length for local variable containers.
48pub const LOCALS_MAX_LENGTH: usize = 10;
49
50/// Default maximum length for local variable strings.
51pub const LOCALS_MAX_STRING: usize = 80;
52
53// ============================================================================
54// Frame
55// ============================================================================
56
57/// A single stack frame in a traceback.
58///
59/// Represents one level in the call stack, including the source location,
60/// function name, source line, and optionally local variables.
61#[derive(Debug, Clone)]
62pub struct Frame {
63    /// The source file path.
64    pub filename: String,
65    /// The line number (1-based).
66    pub lineno: usize,
67    /// The function or method name.
68    pub name: String,
69    /// The source code line (may be empty if unavailable).
70    pub line: String,
71    /// Local variables as debug strings (name -> repr).
72    /// Uses `BTreeMap` for deterministic ordering.
73    pub locals: Option<BTreeMap<String, String>>,
74}
75
76impl Frame {
77    /// Create a new frame with the essential fields.
78    ///
79    /// # Arguments
80    ///
81    /// * `filename` - The source file path.
82    /// * `lineno` - The line number (1-based).
83    /// * `name` - The function or method name.
84    ///
85    /// # Example
86    ///
87    /// ```
88    /// use rich_rs::traceback::Frame;
89    ///
90    /// let frame = Frame::new("src/main.rs", 42, "main");
91    /// ```
92    pub fn new(filename: impl Into<String>, lineno: usize, name: impl Into<String>) -> Self {
93        Self {
94            filename: filename.into(),
95            lineno,
96            name: name.into(),
97            line: String::new(),
98            locals: None,
99        }
100    }
101
102    /// Set the source line for this frame.
103    pub fn with_line(mut self, line: impl Into<String>) -> Self {
104        self.line = line.into();
105        self
106    }
107
108    /// Set the local variables for this frame.
109    pub fn with_locals(mut self, locals: BTreeMap<String, String>) -> Self {
110        self.locals = Some(locals);
111        self
112    }
113
114    /// Add a single local variable.
115    pub fn add_local(&mut self, name: impl Into<String>, value: impl Into<String>) {
116        self.locals
117            .get_or_insert_with(BTreeMap::new)
118            .insert(name.into(), value.into());
119    }
120
121    /// Check if this frame has local variables.
122    pub fn has_locals(&self) -> bool {
123        self.locals.as_ref().is_some_and(|l| !l.is_empty())
124    }
125}
126
127// ============================================================================
128// SyntaxErrorInfo
129// ============================================================================
130
131/// Information about a syntax error.
132///
133/// This is used for special handling of syntax errors which have
134/// additional location information.
135#[derive(Debug, Clone)]
136pub struct SyntaxErrorInfo {
137    /// Column offset where the error occurred.
138    pub offset: usize,
139    /// The source file path.
140    pub filename: String,
141    /// The source line containing the error.
142    pub line: String,
143    /// The line number (1-based).
144    pub lineno: usize,
145    /// The error message.
146    pub msg: String,
147}
148
149impl SyntaxErrorInfo {
150    /// Create new syntax error info.
151    ///
152    /// # Arguments
153    ///
154    /// * `filename` - The source file path.
155    /// * `lineno` - The line number.
156    /// * `offset` - The column offset.
157    /// * `msg` - The error message.
158    pub fn new(
159        filename: impl Into<String>,
160        lineno: usize,
161        offset: usize,
162        msg: impl Into<String>,
163    ) -> Self {
164        Self {
165            offset,
166            filename: filename.into(),
167            line: String::new(),
168            lineno,
169            msg: msg.into(),
170        }
171    }
172
173    /// Set the source line.
174    pub fn with_line(mut self, line: impl Into<String>) -> Self {
175        self.line = line.into();
176        self
177    }
178}
179
180// ============================================================================
181// Stack
182// ============================================================================
183
184/// A single exception stack (one exception in a chain).
185///
186/// Contains the exception type, value, optional syntax error info,
187/// and the list of frames leading to the exception.
188#[derive(Debug, Clone)]
189pub struct Stack {
190    /// The exception type name (e.g., "ValueError", "RuntimeError").
191    pub exc_type: String,
192    /// The exception message/value.
193    pub exc_value: String,
194    /// Syntax error information (for SyntaxError exceptions).
195    pub syntax_error: Option<SyntaxErrorInfo>,
196    /// Whether this exception was caused by another (chained exception).
197    pub is_cause: bool,
198    /// The stack frames for this exception.
199    pub frames: Vec<Frame>,
200}
201
202impl Stack {
203    /// Create a new stack with the exception type and value.
204    ///
205    /// # Arguments
206    ///
207    /// * `exc_type` - The exception type name.
208    /// * `exc_value` - The exception message.
209    ///
210    /// # Example
211    ///
212    /// ```
213    /// use rich_rs::traceback::Stack;
214    ///
215    /// let stack = Stack::new("ValueError", "invalid input");
216    /// ```
217    pub fn new(exc_type: impl Into<String>, exc_value: impl Into<String>) -> Self {
218        Self {
219            exc_type: exc_type.into(),
220            exc_value: exc_value.into(),
221            syntax_error: None,
222            is_cause: false,
223            frames: Vec::new(),
224        }
225    }
226
227    /// Set the syntax error info.
228    pub fn with_syntax_error(mut self, error: SyntaxErrorInfo) -> Self {
229        self.syntax_error = Some(error);
230        self
231    }
232
233    /// Mark this as a caused exception (from exception chaining).
234    pub fn with_is_cause(mut self, is_cause: bool) -> Self {
235        self.is_cause = is_cause;
236        self
237    }
238
239    /// Set the frames for this stack.
240    pub fn with_frames(mut self, frames: Vec<Frame>) -> Self {
241        self.frames = frames;
242        self
243    }
244
245    /// Add a single frame to the stack.
246    pub fn with_frame(mut self, frame: Frame) -> Self {
247        self.frames.push(frame);
248        self
249    }
250
251    /// Add a frame to the stack (mutable version).
252    pub fn add_frame(&mut self, frame: Frame) {
253        self.frames.push(frame);
254    }
255
256    /// Check if this is a syntax error.
257    pub fn is_syntax_error(&self) -> bool {
258        self.syntax_error.is_some()
259    }
260
261    /// Get the number of frames.
262    pub fn frame_count(&self) -> usize {
263        self.frames.len()
264    }
265}
266
267// ============================================================================
268// Trace
269// ============================================================================
270
271/// A complete trace with potentially chained exceptions.
272///
273/// Contains one or more `Stack` objects representing the exception chain.
274/// The last stack is typically the most recent exception.
275#[derive(Debug, Clone)]
276pub struct Trace {
277    /// The exception stacks (may be multiple for chained exceptions).
278    pub stacks: Vec<Stack>,
279}
280
281impl Trace {
282    /// Create a new trace with the given stacks.
283    ///
284    /// # Arguments
285    ///
286    /// * `stacks` - The exception stacks.
287    ///
288    /// # Example
289    ///
290    /// ```
291    /// use rich_rs::traceback::{Stack, Trace};
292    ///
293    /// let stack = Stack::new("Error", "message");
294    /// let trace = Trace::new(vec![stack]);
295    /// ```
296    pub fn new(stacks: Vec<Stack>) -> Self {
297        Self { stacks }
298    }
299
300    /// Create an empty trace.
301    pub fn empty() -> Self {
302        Self { stacks: Vec::new() }
303    }
304
305    /// Add a stack to the trace.
306    pub fn with_stack(mut self, stack: Stack) -> Self {
307        self.stacks.push(stack);
308        self
309    }
310
311    /// Add a stack to the trace (mutable version).
312    pub fn add_stack(&mut self, stack: Stack) {
313        self.stacks.push(stack);
314    }
315
316    /// Get the number of stacks.
317    pub fn stack_count(&self) -> usize {
318        self.stacks.len()
319    }
320
321    /// Check if this trace is empty.
322    pub fn is_empty(&self) -> bool {
323        self.stacks.is_empty()
324    }
325}
326
327// ============================================================================
328// TracebackBuilder
329// ============================================================================
330
331/// Builder for `Traceback` configuration.
332#[derive(Debug, Clone)]
333pub struct TracebackBuilder {
334    trace: Trace,
335    width: Option<usize>,
336    extra_lines: usize,
337    theme: Option<String>,
338    word_wrap: bool,
339    show_locals: bool,
340    locals_max_length: Option<usize>,
341    locals_max_string: Option<usize>,
342    locals_hide_dunder: bool,
343    locals_hide_sunder: bool,
344    indent_guides: bool,
345    suppress: Vec<String>,
346    max_frames: usize,
347}
348
349impl TracebackBuilder {
350    /// Create a new builder with the given trace.
351    pub fn new(trace: Trace) -> Self {
352        Self {
353            trace,
354            width: None,
355            extra_lines: DEFAULT_EXTRA_LINES,
356            theme: None,
357            word_wrap: false,
358            show_locals: false,
359            locals_max_length: Some(LOCALS_MAX_LENGTH),
360            locals_max_string: Some(LOCALS_MAX_STRING),
361            locals_hide_dunder: true,
362            locals_hide_sunder: false,
363            indent_guides: true,
364            suppress: Vec::new(),
365            max_frames: DEFAULT_MAX_FRAMES,
366        }
367    }
368
369    /// Set the width for the traceback display.
370    pub fn width(mut self, width: usize) -> Self {
371        self.width = Some(width);
372        self
373    }
374
375    /// Set the number of extra context lines around the error.
376    pub fn extra_lines(mut self, lines: usize) -> Self {
377        self.extra_lines = lines;
378        self
379    }
380
381    /// Set the syntax highlighting theme name.
382    pub fn theme(mut self, theme: impl Into<String>) -> Self {
383        self.theme = Some(theme.into());
384        self
385    }
386
387    /// Enable or disable word wrapping.
388    pub fn word_wrap(mut self, wrap: bool) -> Self {
389        self.word_wrap = wrap;
390        self
391    }
392
393    /// Enable or disable showing local variables.
394    pub fn show_locals(mut self, show: bool) -> Self {
395        self.show_locals = show;
396        self
397    }
398
399    /// Set the maximum length for local variable containers.
400    pub fn locals_max_length(mut self, max: Option<usize>) -> Self {
401        self.locals_max_length = max;
402        self
403    }
404
405    /// Set the maximum string length for local variables.
406    pub fn locals_max_string(mut self, max: Option<usize>) -> Self {
407        self.locals_max_string = max;
408        self
409    }
410
411    /// Hide locals prefixed with double underscore.
412    pub fn locals_hide_dunder(mut self, hide: bool) -> Self {
413        self.locals_hide_dunder = hide;
414        self
415    }
416
417    /// Hide locals prefixed with single underscore.
418    pub fn locals_hide_sunder(mut self, hide: bool) -> Self {
419        self.locals_hide_sunder = hide;
420        self
421    }
422
423    /// Enable or disable indent guides in code.
424    pub fn indent_guides(mut self, guides: bool) -> Self {
425        self.indent_guides = guides;
426        self
427    }
428
429    /// Add a module/path to suppress from the traceback.
430    pub fn suppress(mut self, path: impl Into<String>) -> Self {
431        self.suppress.push(path.into());
432        self
433    }
434
435    /// Add multiple modules/paths to suppress.
436    pub fn suppress_all(mut self, paths: impl IntoIterator<Item = impl Into<String>>) -> Self {
437        self.suppress.extend(paths.into_iter().map(Into::into));
438        self
439    }
440
441    /// Set the maximum number of frames to display.
442    pub fn max_frames(mut self, max: usize) -> Self {
443        self.max_frames = if max > 0 { max.max(4) } else { 0 };
444        self
445    }
446
447    /// Build the `Traceback`.
448    pub fn build(self) -> Traceback {
449        Traceback {
450            trace: self.trace,
451            width: self.width,
452            extra_lines: self.extra_lines,
453            theme: self.theme,
454            word_wrap: self.word_wrap,
455            show_locals: self.show_locals,
456            locals_max_length: self.locals_max_length,
457            locals_max_string: self.locals_max_string,
458            locals_hide_dunder: self.locals_hide_dunder,
459            locals_hide_sunder: self.locals_hide_sunder,
460            indent_guides: self.indent_guides,
461            suppress: self.suppress,
462            max_frames: self.max_frames,
463        }
464    }
465}
466
467// ============================================================================
468// Traceback
469// ============================================================================
470
471/// Traceback display configuration and data.
472///
473/// Holds a `Trace` and configuration options for rendering.
474/// Rendering is not yet implemented - this is the struct definition only.
475///
476/// # Example
477///
478/// ```
479/// use rich_rs::traceback::{Frame, Stack, Trace, Traceback};
480///
481/// let frame = Frame::new("main.rs", 10, "main")
482///     .with_line("    let x = foo();");
483///
484/// let stack = Stack::new("PanicInfo", "called `Result::unwrap()` on an `Err` value")
485///     .with_frame(frame);
486///
487/// let trace = Trace::new(vec![stack]);
488///
489/// // Using new() with defaults
490/// let tb = Traceback::new(trace.clone());
491///
492/// // Using builder pattern for customization
493/// let tb = Traceback::builder(trace)
494///     .width(100)
495///     .show_locals(true)
496///     .theme("monokai")
497///     .build();
498/// ```
499#[derive(Debug, Clone)]
500pub struct Traceback {
501    /// The trace data.
502    pub trace: Trace,
503    /// Display width (None = use console width).
504    pub width: Option<usize>,
505    /// Number of extra context lines around the error line.
506    pub extra_lines: usize,
507    /// Syntax highlighting theme name.
508    pub theme: Option<String>,
509    /// Enable word wrapping of long lines.
510    pub word_wrap: bool,
511    /// Show local variables in each frame.
512    pub show_locals: bool,
513    /// Maximum length for container locals before abbreviating.
514    pub locals_max_length: Option<usize>,
515    /// Maximum string length for locals before truncating.
516    pub locals_max_string: Option<usize>,
517    /// Hide locals prefixed with double underscore.
518    pub locals_hide_dunder: bool,
519    /// Hide locals prefixed with single underscore.
520    pub locals_hide_sunder: bool,
521    /// Show indent guides in code.
522    pub indent_guides: bool,
523    /// Modules/paths to suppress from the traceback.
524    pub suppress: Vec<String>,
525    /// Maximum number of frames to show (0 = unlimited).
526    pub max_frames: usize,
527}
528
529impl Traceback {
530    /// Create a new traceback with default settings.
531    ///
532    /// # Arguments
533    ///
534    /// * `trace` - The trace data to display.
535    ///
536    /// # Example
537    ///
538    /// ```
539    /// use rich_rs::traceback::{Stack, Trace, Traceback};
540    ///
541    /// let trace = Trace::new(vec![Stack::new("Error", "message")]);
542    /// let tb = Traceback::new(trace);
543    /// ```
544    pub fn new(trace: Trace) -> Self {
545        Self {
546            trace,
547            width: None,
548            extra_lines: DEFAULT_EXTRA_LINES,
549            theme: None,
550            word_wrap: false,
551            show_locals: false,
552            locals_max_length: Some(LOCALS_MAX_LENGTH),
553            locals_max_string: Some(LOCALS_MAX_STRING),
554            locals_hide_dunder: true,
555            locals_hide_sunder: false,
556            indent_guides: true,
557            suppress: Vec::new(),
558            max_frames: DEFAULT_MAX_FRAMES,
559        }
560    }
561
562    /// Create a builder for configuring a traceback.
563    ///
564    /// # Arguments
565    ///
566    /// * `trace` - The trace data to display.
567    ///
568    /// # Example
569    ///
570    /// ```
571    /// use rich_rs::traceback::{Stack, Trace, Traceback};
572    ///
573    /// let trace = Trace::new(vec![Stack::new("Error", "message")]);
574    /// let tb = Traceback::builder(trace)
575    ///     .show_locals(true)
576    ///     .max_frames(50)
577    ///     .build();
578    /// ```
579    pub fn builder(trace: Trace) -> TracebackBuilder {
580        TracebackBuilder::new(trace)
581    }
582
583    /// Get the trace data.
584    pub fn trace(&self) -> &Trace {
585        &self.trace
586    }
587
588    /// Check if local variables should be displayed.
589    pub fn should_show_locals(&self) -> bool {
590        self.show_locals
591    }
592
593    /// Filter locals based on hide settings.
594    ///
595    /// Returns a new `BTreeMap` with hidden variables removed.
596    pub fn filter_locals(&self, locals: &BTreeMap<String, String>) -> BTreeMap<String, String> {
597        locals
598            .iter()
599            .filter(|(name, _)| {
600                // Hide dunder variables if configured
601                if self.locals_hide_dunder && name.starts_with("__") && name.ends_with("__") {
602                    return false;
603                }
604                // Hide sunder variables if configured
605                if self.locals_hide_sunder && name.starts_with('_') && !name.starts_with("__") {
606                    return false;
607                }
608                true
609            })
610            .map(|(k, v)| (k.clone(), v.clone()))
611            .collect()
612    }
613
614    /// Check if a path should be suppressed.
615    pub fn is_suppressed(&self, path: &str) -> bool {
616        self.suppress.iter().any(|s| path.contains(s))
617    }
618}
619
620// ============================================================================
621// Styles
622// ============================================================================
623
624/// Style for traceback title.
625fn traceback_title_style() -> Style {
626    Style::new()
627        .with_color(crate::color::SimpleColor::Standard(9)) // bright red
628        .with_bold(true)
629}
630
631/// Style for traceback border.
632fn traceback_border_style() -> Style {
633    Style::new().with_color(crate::color::SimpleColor::Standard(1)) // red
634}
635
636/// Style for exception type.
637fn exc_type_style() -> Style {
638    Style::new()
639        .with_color(crate::color::SimpleColor::Standard(1)) // red
640        .with_bold(true)
641}
642
643/// Style for file paths.
644fn path_style() -> Style {
645    Style::new().with_color(crate::color::SimpleColor::Standard(5)) // magenta
646}
647
648/// Style for line numbers.
649fn lineno_style() -> Style {
650    Style::new().with_color(crate::color::SimpleColor::Standard(6)) // cyan
651}
652
653/// Style for function names.
654fn function_style() -> Style {
655    Style::new().with_color(crate::color::SimpleColor::Standard(2)) // green
656}
657
658/// Style for "frames hidden" message.
659fn frames_hidden_style() -> Style {
660    Style::new()
661        .with_color(crate::color::SimpleColor::Standard(3)) // yellow
662        .with_italic(true)
663}
664
665/// Style for syntax error offset indicator.
666fn syntax_error_offset_style() -> Style {
667    Style::new()
668        .with_color(crate::color::SimpleColor::Standard(1)) // red
669        .with_bold(true)
670}
671
672// ============================================================================
673// Renderable Implementation
674// ============================================================================
675
676impl Renderable for Traceback {
677    fn render(&self, console: &Console<Stdout>, options: &ConsoleOptions) -> Segments {
678        let mut result = Segments::new();
679
680        // Get display width
681        let width = self
682            .width
683            .unwrap_or(options.max_width)
684            .min(options.max_width);
685        let code_width = width.saturating_sub(8); // Leave room for line numbers and borders
686
687        // Render stacks in reverse order (most recent exception last)
688        let stacks: Vec<&Stack> = self.trace.stacks.iter().rev().collect();
689
690        for (idx, stack) in stacks.iter().enumerate() {
691            let is_last = idx == stacks.len() - 1;
692
693            // Render stack frames if present
694            if !stack.frames.is_empty() {
695                // Render stack content directly to segments
696                let stack_segments = self.render_stack_frames(stack, console, options, code_width);
697
698                // Create a wrapper text from the segments for the panel
699                // We'll use a simpler approach: render directly without panel
700                // since Panel requires 'static lifetime
701
702                // Create panel title
703                let mut title = Text::styled("Traceback ", traceback_title_style());
704                title.append(
705                    "(most recent call last)",
706                    Some(traceback_title_style().with_dim(true)),
707                );
708
709                // Render panel border manually
710                let border_style = traceback_border_style();
711                let box_chars = &ROUNDED;
712
713                // Top border with title
714                let title_str = title.plain_text();
715                let title_len = crate::cells::cell_len(&title_str);
716                let inner_width = width.saturating_sub(2);
717                let title_pad = inner_width.saturating_sub(title_len).saturating_sub(2);
718                let left_pad = title_pad / 2;
719                let right_pad = title_pad - left_pad;
720
721                // Top line: ╭─ Title ─────────────────╮
722                result.push(Segment::styled(
723                    box_chars.top_left.to_string(),
724                    border_style,
725                ));
726                result.push(Segment::styled(
727                    box_chars.top.to_string().repeat(left_pad + 1),
728                    border_style,
729                ));
730                result.extend(title.render(console, options));
731                result.push(Segment::styled(
732                    box_chars.top.to_string().repeat(right_pad + 1),
733                    border_style,
734                ));
735                result.push(Segment::styled(
736                    box_chars.top_right.to_string(),
737                    border_style,
738                ));
739                result.push(Segment::line());
740
741                // Content lines with side borders
742                let segment_vec: Vec<Segment> = stack_segments.iter().cloned().collect();
743                let content_lines = Segment::split_lines(segment_vec);
744                for line_segments in content_lines {
745                    result.push(Segment::styled(
746                        format!("{} ", box_chars.mid_left),
747                        border_style,
748                    ));
749                    result.extend(line_segments.into_iter().map(|s| Segment::from(s)));
750                    // Pad to width
751                    let line_width: usize = result
752                        .iter()
753                        .skip(result.len().saturating_sub(10))
754                        .map(|s| crate::cells::cell_len(&s.text))
755                        .sum();
756                    let padding = inner_width.saturating_sub(line_width.saturating_sub(2));
757                    if padding > 0 {
758                        result.push(Segment::new(" ".repeat(padding)));
759                    }
760                    result.push(Segment::styled(
761                        format!(" {}", box_chars.mid_right),
762                        border_style,
763                    ));
764                    result.push(Segment::line());
765                }
766
767                // Bottom border
768                result.push(Segment::styled(
769                    box_chars.bottom_left.to_string(),
770                    border_style,
771                ));
772                result.push(Segment::styled(
773                    box_chars.bottom.to_string().repeat(inner_width),
774                    border_style,
775                ));
776                result.push(Segment::styled(
777                    box_chars.bottom_right.to_string(),
778                    border_style,
779                ));
780                result.push(Segment::line());
781            }
782
783            // Render syntax error if present
784            if let Some(ref syntax_error) = stack.syntax_error {
785                let syntax_error_segments =
786                    self.render_syntax_error_content(syntax_error, console, options);
787                result.extend(syntax_error_segments);
788                result.push(Segment::line());
789            }
790
791            // Render exception type and value
792            let exc_text = self.render_exception_line(stack);
793            let exc_segments = exc_text.render(console, options);
794            result.extend(exc_segments);
795            result.push(Segment::line());
796
797            // If not the last stack, show chaining message
798            if !is_last {
799                let chaining_msg = if stack.is_cause {
800                    "\nThe above exception was the direct cause of the following exception:\n"
801                } else {
802                    "\nDuring handling of the above exception, another exception occurred:\n"
803                };
804
805                let chaining_text = Text::styled(chaining_msg, Style::new().with_italic(true));
806                let chaining_segments = chaining_text.render(console, options);
807                result.extend(chaining_segments);
808                result.push(Segment::line());
809            }
810        }
811
812        result
813    }
814
815    fn measure(&self, _console: &Console<Stdout>, options: &ConsoleOptions) -> Measurement {
816        // Traceback width is configurable, use it or max_width
817        let width = self
818            .width
819            .unwrap_or(options.max_width)
820            .min(options.max_width);
821        Measurement::new(width, width)
822    }
823}
824
825impl Traceback {
826    /// Render stack frames directly to segments.
827    fn render_stack_frames(
828        &self,
829        stack: &Stack,
830        console: &Console<Stdout>,
831        options: &ConsoleOptions,
832        code_width: usize,
833    ) -> Segments {
834        let mut result = Segments::new();
835        let highlighter = path_highlighter();
836        let frames = &stack.frames;
837
838        // Calculate frame exclusion range if needed
839        let exclude_range = if self.max_frames > 0 && frames.len() > self.max_frames {
840            let half = self.max_frames / 2;
841            Some(half..(frames.len() - half))
842        } else {
843            None
844        };
845
846        let mut excluded_shown = false;
847
848        for (idx, frame) in frames.iter().enumerate() {
849            // Check if this frame should be excluded
850            if let Some(ref range) = exclude_range {
851                if range.contains(&idx) {
852                    // Show "frames hidden" message once
853                    if !excluded_shown {
854                        let msg = format!("\n... {} frames hidden ...\n", range.len());
855                        let hidden_text = Text::styled(&msg, frames_hidden_style());
856                        result.extend(hidden_text.render(console, options));
857                        excluded_shown = true;
858                    }
859                    continue;
860                }
861            }
862
863            // Check if frame should be suppressed
864            let suppressed = self.is_suppressed(&frame.filename);
865
866            // Add blank line between frames (except first)
867            if idx > 0 && !frame.filename.starts_with('<') {
868                result.push(Segment::line());
869            }
870
871            // Render frame location
872            let location_text = self.render_frame_location(frame, &highlighter);
873            result.extend(location_text.render(console, options));
874            result.push(Segment::line());
875
876            // Skip code display for special frames or suppressed frames
877            if frame.filename.starts_with('<') || suppressed {
878                // Still show locals if available
879                if self.show_locals && frame.has_locals() {
880                    self.render_locals(&mut result, frame, console, options);
881                }
882                continue;
883            }
884
885            // Try to read and display source code
886            if let Some(code) = self.read_source_code(&frame.filename) {
887                let lexer = self.guess_lexer(&frame.filename);
888
889                // Calculate line range
890                let start_line = frame.lineno.saturating_sub(self.extra_lines);
891                let end_line = frame.lineno + self.extra_lines;
892
893                // Create syntax highlighter
894                let mut syntax = Syntax::new(&code, lexer)
895                    .with_line_numbers(true)
896                    .with_line_range(Some(start_line), Some(end_line))
897                    .with_highlight_lines(vec![frame.lineno])
898                    .with_indent_guides(self.indent_guides)
899                    .with_word_wrap(self.word_wrap);
900
901                if let Some(ref theme) = self.theme {
902                    syntax = syntax.with_theme(theme);
903                }
904
905                if code_width > 0 {
906                    syntax = syntax.with_code_width(code_width);
907                }
908
909                result.extend(syntax.render(console, options));
910            }
911
912            // Show locals if enabled
913            if self.show_locals && frame.has_locals() {
914                result.push(Segment::line());
915                self.render_locals(&mut result, frame, console, options);
916            }
917        }
918
919        result
920    }
921
922    /// Render a frame location line (filename:lineno in function_name).
923    fn render_frame_location(&self, frame: &Frame, highlighter: &RegexHighlighter) -> Text {
924        let path = Path::new(&frame.filename);
925
926        if path.exists() {
927            // File exists: show full path
928            let mut text = Text::new();
929
930            // Path with highlighting (dim directory, bold filename)
931            let mut path_text = Text::styled(&frame.filename, path_style());
932            highlighter.highlight(&mut path_text);
933            text.append_text(&path_text);
934
935            // :lineno
936            text.append(":", None);
937            text.append(&frame.lineno.to_string(), Some(lineno_style()));
938
939            // in function_name
940            text.append(" in ", None);
941            text.append(&frame.name, Some(function_style()));
942
943            text
944        } else {
945            // File doesn't exist: show function:lineno format
946            let mut text = Text::new();
947            text.append("in ", None);
948            text.append(&frame.name, Some(function_style()));
949            text.append(":", None);
950            text.append(&frame.lineno.to_string(), Some(lineno_style()));
951            text
952        }
953    }
954
955    /// Render local variables for a frame.
956    fn render_locals(
957        &self,
958        result: &mut Segments,
959        frame: &Frame,
960        console: &Console<Stdout>,
961        options: &ConsoleOptions,
962    ) {
963        if let Some(ref locals) = frame.locals {
964            // Filter locals based on settings
965            let filtered = self.filter_locals(locals);
966
967            if !filtered.is_empty() {
968                let scope = render_scope(
969                    &filtered,
970                    Some("locals"),
971                    true, // sort_keys
972                    self.indent_guides,
973                    self.locals_max_length,
974                    self.locals_max_string,
975                );
976                result.extend(scope.render(console, options));
977            }
978        }
979    }
980
981    /// Render syntax error content directly to segments.
982    fn render_syntax_error_content(
983        &self,
984        error: &SyntaxErrorInfo,
985        console: &Console<Stdout>,
986        options: &ConsoleOptions,
987    ) -> Segments {
988        let mut result = Segments::new();
989
990        let highlighter = path_highlighter();
991        let repr_hl = repr_highlighter();
992
993        // Show file location if not stdin
994        if error.filename != "<stdin>" {
995            let path = Path::new(&error.filename);
996            if path.exists() {
997                let mut location = Text::new();
998                location.append(" ", None);
999
1000                let mut path_text = Text::styled(&error.filename, path_style());
1001                highlighter.highlight(&mut path_text);
1002                location.append_text(&path_text);
1003
1004                location.append(":", None);
1005                location.append(&error.lineno.to_string(), Some(lineno_style()));
1006
1007                result.extend(location.render(console, options));
1008                result.push(Segment::line());
1009            }
1010        }
1011
1012        // Show the error line
1013        let mut error_line = Text::plain(error.line.trim_end());
1014        repr_hl.highlight(&mut error_line);
1015
1016        // Underline the error position
1017        let offset = error
1018            .offset
1019            .saturating_sub(1)
1020            .min(error_line.plain_text().len());
1021        if offset < error_line.plain_text().len() {
1022            error_line.stylize(
1023                offset,
1024                offset + 1,
1025                Style::new().with_bold(true).with_underline(true),
1026            );
1027        }
1028
1029        result.extend(error_line.render(console, options));
1030
1031        // Show offset indicator
1032        let indicator = format!("\n{}▲", " ".repeat(offset));
1033        let indicator_text = Text::styled(&indicator, syntax_error_offset_style());
1034        result.extend(indicator_text.render(console, options));
1035
1036        result
1037    }
1038
1039    /// Render the exception type and value line.
1040    fn render_exception_line(&self, stack: &Stack) -> Text {
1041        let highlighter = repr_highlighter();
1042
1043        let mut text = Text::new();
1044
1045        // Exception type
1046        text.append(&format!("{}: ", stack.exc_type), Some(exc_type_style()));
1047
1048        // Exception value (highlighted)
1049        let mut value_text = Text::plain(&stack.exc_value);
1050        highlighter.highlight(&mut value_text);
1051        text.append_text(&value_text);
1052
1053        text
1054    }
1055
1056    /// Read source code from a file.
1057    fn read_source_code(&self, filename: &str) -> Option<String> {
1058        let path = Path::new(filename);
1059        if path.exists() {
1060            fs::read_to_string(path).ok()
1061        } else {
1062            None
1063        }
1064    }
1065
1066    /// Guess the lexer name for syntax highlighting.
1067    fn guess_lexer(&self, filename: &str) -> &'static str {
1068        let ext = Path::new(filename)
1069            .extension()
1070            .and_then(|e| e.to_str())
1071            .unwrap_or("");
1072
1073        match ext {
1074            "rs" => "rust",
1075            "py" => "python",
1076            "js" | "mjs" => "javascript",
1077            "ts" | "mts" => "typescript",
1078            "tsx" => "tsx",
1079            "jsx" => "jsx",
1080            "c" | "h" => "c",
1081            "cpp" | "cc" | "cxx" | "hpp" | "hxx" => "cpp",
1082            "go" => "go",
1083            "java" => "java",
1084            "rb" => "ruby",
1085            "sh" | "bash" => "bash",
1086            "json" => "json",
1087            "toml" => "toml",
1088            "yaml" | "yml" => "yaml",
1089            "md" | "markdown" => "markdown",
1090            "html" | "htm" => "html",
1091            "css" => "css",
1092            "sql" => "sql",
1093            "xml" => "xml",
1094            _ => "text",
1095        }
1096    }
1097}
1098
1099// ============================================================================
1100// Panic Hook Installation
1101// ============================================================================
1102
1103/// Install a rich panic hook that displays panics with syntax highlighting.
1104///
1105/// This replaces the default panic hook with one that uses `Traceback` to
1106/// display the panic information.
1107///
1108/// # Example
1109///
1110/// ```no_run
1111/// use rich_rs::traceback::install;
1112///
1113/// install();
1114///
1115/// // Now panics will be displayed with rich formatting
1116/// panic!("Something went wrong!");
1117/// ```
1118pub fn install() {
1119    install_with_options(TracebackBuilder::new(Trace::empty()));
1120}
1121
1122/// Install a rich panic hook with custom options.
1123///
1124/// # Example
1125///
1126/// ```no_run
1127/// use rich_rs::traceback::{install_with_options, Trace, TracebackBuilder};
1128///
1129/// install_with_options(
1130///     TracebackBuilder::new(Trace::empty())
1131///         .show_locals(true)
1132///         .width(120)
1133/// );
1134/// ```
1135pub fn install_with_options(builder: TracebackBuilder) {
1136    use crate::ColorSystem;
1137    use std::io::{self, Write};
1138
1139    let config = builder.build();
1140
1141    std::panic::set_hook(Box::new(move |panic_info| {
1142        // Create a stdout console for rendering (we'll output to stderr manually)
1143        let console = Console::new();
1144        let options = console.options();
1145
1146        // Get panic location
1147        let location = panic_info.location();
1148        let (filename, lineno, _col) = location
1149            .map(|l| (l.file().to_string(), l.line() as usize, l.column() as usize))
1150            .unwrap_or_else(|| ("<unknown>".to_string(), 0, 0));
1151
1152        // Get panic message
1153        let message = if let Some(s) = panic_info.payload().downcast_ref::<&str>() {
1154            s.to_string()
1155        } else if let Some(s) = panic_info.payload().downcast_ref::<String>() {
1156            s.clone()
1157        } else {
1158            "Box<dyn Any>".to_string()
1159        };
1160
1161        // Build a simple trace with one frame
1162        let frame = Frame::new(&filename, lineno, "panic");
1163        let stack = Stack::new("panic", &message).with_frame(frame);
1164        let trace = Trace::new(vec![stack]);
1165
1166        // Create traceback with user's configuration
1167        let traceback = Traceback {
1168            trace,
1169            width: config.width,
1170            extra_lines: config.extra_lines,
1171            theme: config.theme.clone(),
1172            word_wrap: config.word_wrap,
1173            show_locals: false, // Can't get locals from panic
1174            locals_max_length: config.locals_max_length,
1175            locals_max_string: config.locals_max_string,
1176            locals_hide_dunder: config.locals_hide_dunder,
1177            locals_hide_sunder: config.locals_hide_sunder,
1178            indent_guides: config.indent_guides,
1179            suppress: config.suppress.clone(),
1180            max_frames: config.max_frames,
1181        };
1182
1183        // Render the traceback
1184        let segments = traceback.render(&console, &options);
1185
1186        // Determine color system (assume TrueColor for stderr in most terminals)
1187        let color_system = ColorSystem::TrueColor;
1188
1189        // Output to stderr with ANSI styling
1190        let stderr = io::stderr();
1191        let mut handle = stderr.lock();
1192        for segment in segments.iter() {
1193            if segment.is_control() {
1194                continue;
1195            }
1196            if let Some(style) = segment.style {
1197                let _ = write!(handle, "{}", style.render(&segment.text, color_system));
1198            } else {
1199                let _ = write!(handle, "{}", segment.text);
1200            }
1201        }
1202        let _ = writeln!(handle);
1203        let _ = handle.flush();
1204    }));
1205}
1206
1207// ============================================================================
1208// Tests
1209// ============================================================================
1210
1211#[cfg(test)]
1212mod tests {
1213    use super::*;
1214
1215    // ==================== Frame tests ====================
1216
1217    #[test]
1218    fn test_frame_new() {
1219        let frame = Frame::new("main.rs", 42, "main");
1220        assert_eq!(frame.filename, "main.rs");
1221        assert_eq!(frame.lineno, 42);
1222        assert_eq!(frame.name, "main");
1223        assert!(frame.line.is_empty());
1224        assert!(frame.locals.is_none());
1225    }
1226
1227    #[test]
1228    fn test_frame_with_line() {
1229        let frame = Frame::new("test.rs", 10, "test").with_line("    let x = 42;");
1230        assert_eq!(frame.line, "    let x = 42;");
1231    }
1232
1233    #[test]
1234    fn test_frame_with_locals() {
1235        let mut locals = BTreeMap::new();
1236        locals.insert("x".to_string(), "42".to_string());
1237
1238        let frame = Frame::new("test.rs", 10, "test").with_locals(locals);
1239        assert!(frame.has_locals());
1240        assert_eq!(frame.locals.unwrap().get("x"), Some(&"42".to_string()));
1241    }
1242
1243    #[test]
1244    fn test_frame_add_local() {
1245        let mut frame = Frame::new("test.rs", 10, "test");
1246        frame.add_local("x", "42");
1247        frame.add_local("y", "100");
1248
1249        assert!(frame.has_locals());
1250        let locals = frame.locals.unwrap();
1251        assert_eq!(locals.len(), 2);
1252    }
1253
1254    // ==================== SyntaxErrorInfo tests ====================
1255
1256    #[test]
1257    fn test_syntax_error_info_new() {
1258        let info = SyntaxErrorInfo::new("test.rs", 5, 10, "unexpected token");
1259        assert_eq!(info.filename, "test.rs");
1260        assert_eq!(info.lineno, 5);
1261        assert_eq!(info.offset, 10);
1262        assert_eq!(info.msg, "unexpected token");
1263    }
1264
1265    #[test]
1266    fn test_syntax_error_info_with_line() {
1267        let info = SyntaxErrorInfo::new("test.rs", 5, 10, "error").with_line("let x = ;");
1268        assert_eq!(info.line, "let x = ;");
1269    }
1270
1271    // ==================== Stack tests ====================
1272
1273    #[test]
1274    fn test_stack_new() {
1275        let stack = Stack::new("ValueError", "invalid input");
1276        assert_eq!(stack.exc_type, "ValueError");
1277        assert_eq!(stack.exc_value, "invalid input");
1278        assert!(!stack.is_cause);
1279        assert!(stack.frames.is_empty());
1280        assert!(!stack.is_syntax_error());
1281    }
1282
1283    #[test]
1284    fn test_stack_with_frame() {
1285        let frame = Frame::new("test.rs", 10, "test");
1286        let stack = Stack::new("Error", "msg").with_frame(frame);
1287        assert_eq!(stack.frame_count(), 1);
1288    }
1289
1290    #[test]
1291    fn test_stack_with_frames() {
1292        let frames = vec![Frame::new("a.rs", 1, "a"), Frame::new("b.rs", 2, "b")];
1293        let stack = Stack::new("Error", "msg").with_frames(frames);
1294        assert_eq!(stack.frame_count(), 2);
1295    }
1296
1297    #[test]
1298    fn test_stack_add_frame() {
1299        let mut stack = Stack::new("Error", "msg");
1300        stack.add_frame(Frame::new("a.rs", 1, "a"));
1301        stack.add_frame(Frame::new("b.rs", 2, "b"));
1302        assert_eq!(stack.frame_count(), 2);
1303    }
1304
1305    #[test]
1306    fn test_stack_with_syntax_error() {
1307        let syntax_err = SyntaxErrorInfo::new("test.rs", 5, 10, "error");
1308        let stack = Stack::new("SyntaxError", "msg").with_syntax_error(syntax_err);
1309        assert!(stack.is_syntax_error());
1310    }
1311
1312    #[test]
1313    fn test_stack_is_cause() {
1314        let stack = Stack::new("Error", "caused by").with_is_cause(true);
1315        assert!(stack.is_cause);
1316    }
1317
1318    // ==================== Trace tests ====================
1319
1320    #[test]
1321    fn test_trace_new() {
1322        let stacks = vec![Stack::new("Error", "msg")];
1323        let trace = Trace::new(stacks);
1324        assert_eq!(trace.stack_count(), 1);
1325        assert!(!trace.is_empty());
1326    }
1327
1328    #[test]
1329    fn test_trace_empty() {
1330        let trace = Trace::empty();
1331        assert!(trace.is_empty());
1332        assert_eq!(trace.stack_count(), 0);
1333    }
1334
1335    #[test]
1336    fn test_trace_with_stack() {
1337        let trace = Trace::empty()
1338            .with_stack(Stack::new("E1", "m1"))
1339            .with_stack(Stack::new("E2", "m2"));
1340        assert_eq!(trace.stack_count(), 2);
1341    }
1342
1343    #[test]
1344    fn test_trace_add_stack() {
1345        let mut trace = Trace::empty();
1346        trace.add_stack(Stack::new("E1", "m1"));
1347        trace.add_stack(Stack::new("E2", "m2"));
1348        assert_eq!(trace.stack_count(), 2);
1349    }
1350
1351    // ==================== Traceback tests ====================
1352
1353    #[test]
1354    fn test_traceback_new() {
1355        let trace = Trace::new(vec![Stack::new("Error", "msg")]);
1356        let tb = Traceback::new(trace);
1357
1358        assert!(tb.width.is_none());
1359        assert_eq!(tb.extra_lines, DEFAULT_EXTRA_LINES);
1360        assert!(tb.theme.is_none());
1361        assert!(!tb.word_wrap);
1362        assert!(!tb.show_locals);
1363        assert!(tb.locals_hide_dunder);
1364        assert!(!tb.locals_hide_sunder);
1365        assert!(tb.indent_guides);
1366        assert!(tb.suppress.is_empty());
1367        assert_eq!(tb.max_frames, DEFAULT_MAX_FRAMES);
1368    }
1369
1370    #[test]
1371    fn test_traceback_builder() {
1372        let trace = Trace::new(vec![Stack::new("Error", "msg")]);
1373        let tb = Traceback::builder(trace)
1374            .width(100)
1375            .extra_lines(5)
1376            .theme("monokai")
1377            .word_wrap(true)
1378            .show_locals(true)
1379            .locals_max_length(Some(20))
1380            .locals_max_string(Some(100))
1381            .locals_hide_dunder(false)
1382            .locals_hide_sunder(true)
1383            .indent_guides(false)
1384            .suppress("/usr/lib")
1385            .suppress_all(vec!["site-packages"])
1386            .max_frames(50)
1387            .build();
1388
1389        assert_eq!(tb.width, Some(100));
1390        assert_eq!(tb.extra_lines, 5);
1391        assert_eq!(tb.theme, Some("monokai".to_string()));
1392        assert!(tb.word_wrap);
1393        assert!(tb.show_locals);
1394        assert_eq!(tb.locals_max_length, Some(20));
1395        assert_eq!(tb.locals_max_string, Some(100));
1396        assert!(!tb.locals_hide_dunder);
1397        assert!(tb.locals_hide_sunder);
1398        assert!(!tb.indent_guides);
1399        assert_eq!(tb.suppress.len(), 2);
1400        assert_eq!(tb.max_frames, 50);
1401    }
1402
1403    #[test]
1404    fn test_traceback_max_frames_minimum() {
1405        let trace = Trace::empty();
1406        // If max_frames > 0, it should be at least 4
1407        let tb = Traceback::builder(trace).max_frames(2).build();
1408        assert_eq!(tb.max_frames, 4);
1409    }
1410
1411    #[test]
1412    fn test_traceback_max_frames_zero() {
1413        let trace = Trace::empty();
1414        // Zero means unlimited
1415        let tb = Traceback::builder(trace).max_frames(0).build();
1416        assert_eq!(tb.max_frames, 0);
1417    }
1418
1419    #[test]
1420    fn test_traceback_filter_locals() {
1421        let trace = Trace::empty();
1422        let tb = Traceback::builder(trace)
1423            .locals_hide_dunder(true)
1424            .locals_hide_sunder(true)
1425            .build();
1426
1427        let mut locals = BTreeMap::new();
1428        locals.insert("x".to_string(), "1".to_string());
1429        locals.insert("_private".to_string(), "2".to_string());
1430        locals.insert("__dunder__".to_string(), "3".to_string());
1431        locals.insert("normal_var".to_string(), "4".to_string());
1432
1433        let filtered = tb.filter_locals(&locals);
1434        assert!(filtered.contains_key("x"));
1435        assert!(filtered.contains_key("normal_var"));
1436        assert!(!filtered.contains_key("_private")); // sunder hidden
1437        assert!(!filtered.contains_key("__dunder__")); // dunder hidden
1438    }
1439
1440    #[test]
1441    fn test_traceback_filter_locals_show_all() {
1442        let trace = Trace::empty();
1443        let tb = Traceback::builder(trace)
1444            .locals_hide_dunder(false)
1445            .locals_hide_sunder(false)
1446            .build();
1447
1448        let mut locals = BTreeMap::new();
1449        locals.insert("x".to_string(), "1".to_string());
1450        locals.insert("_private".to_string(), "2".to_string());
1451        locals.insert("__dunder__".to_string(), "3".to_string());
1452
1453        let filtered = tb.filter_locals(&locals);
1454        assert_eq!(filtered.len(), 3);
1455    }
1456
1457    #[test]
1458    fn test_traceback_is_suppressed() {
1459        let trace = Trace::empty();
1460        let tb = Traceback::builder(trace)
1461            .suppress("/usr/lib/python")
1462            .suppress("site-packages")
1463            .build();
1464
1465        assert!(tb.is_suppressed("/usr/lib/python/foo.py"));
1466        assert!(tb.is_suppressed("/home/user/.local/lib/site-packages/bar.py"));
1467        assert!(!tb.is_suppressed("/home/user/project/main.py"));
1468    }
1469
1470    #[test]
1471    fn test_traceback_should_show_locals() {
1472        let trace = Trace::empty();
1473        let tb1 = Traceback::new(trace.clone());
1474        let tb2 = Traceback::builder(trace).show_locals(true).build();
1475
1476        assert!(!tb1.should_show_locals());
1477        assert!(tb2.should_show_locals());
1478    }
1479
1480    // ==================== Send + Sync tests ====================
1481
1482    #[test]
1483    fn test_frame_is_send_sync() {
1484        fn assert_send<T: Send>() {}
1485        fn assert_sync<T: Sync>() {}
1486        assert_send::<Frame>();
1487        assert_sync::<Frame>();
1488    }
1489
1490    #[test]
1491    fn test_stack_is_send_sync() {
1492        fn assert_send<T: Send>() {}
1493        fn assert_sync<T: Sync>() {}
1494        assert_send::<Stack>();
1495        assert_sync::<Stack>();
1496    }
1497
1498    #[test]
1499    fn test_trace_is_send_sync() {
1500        fn assert_send<T: Send>() {}
1501        fn assert_sync<T: Sync>() {}
1502        assert_send::<Trace>();
1503        assert_sync::<Trace>();
1504    }
1505
1506    #[test]
1507    fn test_traceback_is_send_sync() {
1508        fn assert_send<T: Send>() {}
1509        fn assert_sync<T: Sync>() {}
1510        assert_send::<Traceback>();
1511        assert_sync::<Traceback>();
1512    }
1513
1514    // ==================== Rendering tests ====================
1515
1516    #[test]
1517    fn test_traceback_render_basic() {
1518        use crate::{Console, Renderable};
1519
1520        let frame = Frame::new("test.rs", 42, "test_function").with_line("    let x = 42;");
1521        let stack = Stack::new("RuntimeError", "Something went wrong").with_frame(frame);
1522        let trace = Trace::new(vec![stack]);
1523        let tb = Traceback::new(trace);
1524
1525        let console = Console::new();
1526        let options = console.options();
1527        let segments = tb.render(&console, &options);
1528
1529        // Should produce some output
1530        assert!(!segments.is_empty());
1531
1532        // Should contain the exception type and message
1533        let output: String = segments.iter().map(|s| s.text.to_string()).collect();
1534        assert!(output.contains("RuntimeError"));
1535        assert!(output.contains("Something went wrong"));
1536        assert!(output.contains("Traceback"));
1537    }
1538
1539    #[test]
1540    fn test_traceback_render_with_chaining() {
1541        use crate::{Console, Renderable};
1542
1543        // Create chained exception (stack order matters for rendering)
1544        // Stacks are rendered in reverse order, so stack1 appears first in output
1545        let frame1 = Frame::new("inner.rs", 10, "inner_fn");
1546        let stack1 = Stack::new("ValueError", "inner error").with_frame(frame1);
1547
1548        let frame2 = Frame::new("outer.rs", 20, "outer_fn");
1549        let stack2 = Stack::new("RuntimeError", "outer error")
1550            .with_frame(frame2)
1551            .with_is_cause(true); // This stack was caused by the previous one
1552
1553        let trace = Trace::new(vec![stack1, stack2]);
1554        let tb = Traceback::new(trace);
1555
1556        let console = Console::new();
1557        let options = console.options();
1558        let segments = tb.render(&console, &options);
1559
1560        let output: String = segments.iter().map(|s| s.text.to_string()).collect();
1561
1562        // Should contain both exception types
1563        assert!(output.contains("ValueError"));
1564        assert!(output.contains("RuntimeError"));
1565        // Should contain chaining message (between the two stacks)
1566        assert!(output.contains("above exception") || output.contains("another exception"));
1567    }
1568
1569    #[test]
1570    fn test_traceback_render_empty() {
1571        use crate::{Console, Renderable};
1572
1573        let trace = Trace::empty();
1574        let tb = Traceback::new(trace);
1575
1576        let console = Console::new();
1577        let options = console.options();
1578        let segments = tb.render(&console, &options);
1579
1580        // Empty trace should produce empty output
1581        assert!(segments.is_empty());
1582    }
1583
1584    #[test]
1585    fn test_traceback_render_syntax_error() {
1586        use crate::{Console, Renderable};
1587
1588        let syntax_err =
1589            SyntaxErrorInfo::new("test.py", 5, 10, "unexpected token").with_line("def foo(:");
1590
1591        let stack = Stack::new("SyntaxError", "invalid syntax").with_syntax_error(syntax_err);
1592        let trace = Trace::new(vec![stack]);
1593        let tb = Traceback::new(trace);
1594
1595        let console = Console::new();
1596        let options = console.options();
1597        let segments = tb.render(&console, &options);
1598
1599        let output: String = segments.iter().map(|s| s.text.to_string()).collect();
1600
1601        // Should contain the syntax error info
1602        assert!(output.contains("SyntaxError"));
1603        assert!(output.contains("invalid syntax"));
1604        // Should contain the error indicator
1605        assert!(output.contains("▲"));
1606    }
1607}