Skip to main content

rich/
log_render.rs

1//! Rendering log records.
2//!
3//! Port of upstream `rich/_log_render.py`. A [`LogRender`] lays one log record
4//! out as a borderless [`Table::grid`] row: an optional time (blanked when it
5//! repeats the previous record's), an optional fixed-width level, the message
6//! (which takes the remaining width and folds), and an optional `path:line`
7//! linked to the source file. It remembers the last time it showed, as the
8//! upstream callable does.
9//!
10//! Upstream formats a `datetime` with `strftime`; this port takes the time
11//! already formatted, keeping the core free of a date-time dependency. The
12//! `rich-ext` log and tracing handlers format record times for it.
13//! [`LogRecord`] is a one-record convenience over it.
14
15use std::cell::RefCell;
16
17use crate::console::{Console, ConsoleOptions, Overflow};
18use crate::measure::Measurement;
19use crate::protocol::Renderable;
20use crate::segment::Segment;
21use crate::style::{Style, StyleType};
22use crate::table::Table;
23use crate::text::Text;
24
25/// Lays out log records. Port of `rich._log_render.LogRender`.
26pub struct LogRender {
27    show_time: bool,
28    show_level: bool,
29    show_path: bool,
30    omit_repeated_times: bool,
31    level_width: Option<usize>,
32    last_time: RefCell<Option<Text>>,
33}
34
35impl Default for LogRender {
36    fn default() -> Self {
37        LogRender {
38            show_time: true,
39            show_level: false,
40            show_path: true,
41            omit_repeated_times: true,
42            level_width: Some(8),
43            last_time: RefCell::new(None),
44        }
45    }
46}
47
48impl LogRender {
49    /// Upstream's defaults: time and path shown, level hidden, repeated times
50    /// omitted, level column 8 cells wide.
51    pub fn new() -> Self {
52        LogRender::default()
53    }
54
55    /// Show the time column (upstream `show_time`).
56    pub fn show_time(mut self, show: bool) -> Self {
57        self.show_time = show;
58        self
59    }
60
61    /// Show the level column (upstream `show_level`).
62    pub fn show_level(mut self, show: bool) -> Self {
63        self.show_level = show;
64        self
65    }
66
67    /// Show the path column (upstream `show_path`).
68    pub fn show_path(mut self, show: bool) -> Self {
69        self.show_path = show;
70        self
71    }
72
73    /// Blank a time equal to the previous record's (upstream
74    /// `omit_repeated_times`).
75    pub fn omit_repeated_times(mut self, omit: bool) -> Self {
76        self.omit_repeated_times = omit;
77        self
78    }
79
80    /// The level column's width, or `None` to fit its content (upstream
81    /// `level_width`).
82    pub fn level_width(mut self, width: Option<usize>) -> Self {
83        self.level_width = width;
84        self
85    }
86
87    /// Lay out one record. Port of `LogRender.__call__`: `time` is the
88    /// formatted log time, `level` the styled level text (see [`level_text`]),
89    /// and `link_path` makes the path a `file://` hyperlink.
90    #[allow(clippy::too_many_arguments)]
91    pub fn render(
92        &self,
93        console: &Console,
94        message: Text,
95        time: Option<Text>,
96        level: Text,
97        path: Option<&str>,
98        line_no: Option<u32>,
99        link_path: Option<&str>,
100    ) -> Table {
101        let style = |name: &str| {
102            console
103                .get_style(&StyleType::from(name))
104                .unwrap_or_default()
105        };
106        let mut output = Table::grid().padding(0, 1, 0, 1).expand(true);
107        if self.show_time {
108            output.add_column("").column_style(style("log.time"));
109        }
110        if self.show_level {
111            output.add_column("").column_style(style("log.level"));
112            if let Some(width) = self.level_width {
113                output.column_width(width);
114            }
115        }
116        output
117            .add_column("")
118            .column_ratio(1)
119            .column_style(style("log.message"))
120            .column_overflow(Overflow::Fold);
121        let path = path
122            .filter(|_| self.show_path)
123            .filter(|path| !path.is_empty());
124        if path.is_some() {
125            output.add_column("").column_style(style("log.path"));
126        }
127
128        let mut row = Vec::new();
129        if self.show_time {
130            let display = time.unwrap_or_default();
131            let mut last = self.last_time.borrow_mut();
132            let repeated = last.as_ref().is_some_and(|last| same_text(last, &display));
133            if repeated && self.omit_repeated_times {
134                row.push(Text::new(" ".repeat(display.plain().chars().count())));
135            } else {
136                row.push(display.clone());
137                *last = Some(display);
138            }
139        }
140        if self.show_level {
141            row.push(level);
142        }
143        row.push(message);
144        if let Some(path) = path {
145            let link = |target: String| Style::new().with_link(target);
146            let mut path_text = Text::new("");
147            path_text.append(
148                path,
149                link_path.map(|link_path| link(format!("file://{link_path}")).into()),
150            );
151            if let Some(line_no) = line_no.filter(|line| *line > 0) {
152                path_text.append(":", None);
153                path_text.append(
154                    &line_no.to_string(),
155                    link_path.map(|link_path| link(format!("file://{link_path}#{line_no}")).into()),
156                );
157            }
158            row.push(path_text);
159        }
160        output.add_row_text(row);
161        output
162    }
163}
164
165/// `Text.__eq__`: the same characters and the same spans.
166fn same_text(a: &Text, b: &Text) -> bool {
167    a.plain() == b.plain() && a.spans() == b.spans()
168}
169
170/// A level name as upstream's `RichHandler.get_level_text` styles it: padded
171/// to 8 cells, in `logging.level.<name>`.
172pub fn level_text(name: &str) -> Text {
173    Text::styled(
174        format!("{name:<8}"),
175        format!("logging.level.{}", name.to_lowercase()),
176    )
177}
178
179/// Log severity, mirroring the `log` crate's five levels.
180#[derive(Debug, Clone, Copy, PartialEq, Eq)]
181pub enum LogLevel {
182    Trace,
183    Debug,
184    Info,
185    Warn,
186    Error,
187}
188
189impl LogLevel {
190    /// The level name as Python's `logging` spells it (`WARNING`, not `WARN`);
191    /// `TRACE`, which Python lacks, uses `logging.level.notset`.
192    pub fn name(self) -> &'static str {
193        match self {
194            LogLevel::Trace => "TRACE",
195            LogLevel::Debug => "DEBUG",
196            LogLevel::Info => "INFO",
197            LogLevel::Warn => "WARNING",
198            LogLevel::Error => "ERROR",
199        }
200    }
201
202    /// The styled level column for this level.
203    pub fn text(self) -> Text {
204        match self {
205            LogLevel::Trace => Text::styled(format!("{:<8}", "TRACE"), "logging.level.notset"),
206            level => level_text(level.name()),
207        }
208    }
209}
210
211/// One log record, rendered through a fresh [`LogRender`] with the level
212/// shown. A convenience for printing a single record; a stream of records
213/// should share one [`LogRender`] so repeated times are omitted.
214pub struct LogRecord {
215    level: LogLevel,
216    message: String,
217    time: Option<String>,
218    path: Option<String>,
219    line_no: Option<u32>,
220}
221
222impl LogRecord {
223    /// A record at `level` with `message`, which is plain text.
224    pub fn new(level: LogLevel, message: impl Into<String>) -> Self {
225        LogRecord {
226            level,
227            message: message.into(),
228            time: None,
229            path: None,
230            line_no: None,
231        }
232    }
233
234    /// The formatted time for the time column.
235    pub fn time(mut self, time: impl Into<String>) -> Self {
236        self.time = Some(time.into());
237        self
238    }
239
240    /// The source path for the path column.
241    pub fn path(mut self, path: impl Into<String>) -> Self {
242        self.path = Some(path.into());
243        self
244    }
245
246    /// The source line, shown after the path.
247    pub fn line(mut self, line: u32) -> Self {
248        self.line_no = Some(line);
249        self
250    }
251
252    fn table(&self, console: &Console) -> Table {
253        LogRender::new()
254            .show_level(true)
255            .show_time(self.time.is_some())
256            .render(
257                console,
258                Text::new(self.message.clone()),
259                self.time.as_deref().map(Text::new),
260                self.level.text(),
261                self.path.as_deref(),
262                self.line_no,
263                None,
264            )
265    }
266}
267
268impl Renderable for LogRecord {
269    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
270        self.table(console).rich_render(console, options)
271    }
272
273    fn measure(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
274        self.table(console).measure(console, options)
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use crate::color::ColorSystem;
282
283    fn console() -> Console {
284        Console::builder()
285            .force_terminal(true)
286            .color_system(Some(ColorSystem::Truecolor))
287            .width(40)
288            .highlight(false)
289            .build()
290    }
291
292    #[test]
293    fn a_repeated_time_is_blanked() {
294        let console = console();
295        let render = LogRender::new();
296        let first = console.render_to_string(&render.render(
297            &console,
298            Text::new("one"),
299            Some(Text::new("[12:00]")),
300            Text::new(""),
301            None,
302            None,
303            None,
304        ));
305        let second = console.render_to_string(&render.render(
306            &console,
307            Text::new("two"),
308            Some(Text::new("[12:00]")),
309            Text::new(""),
310            None,
311            None,
312            None,
313        ));
314        assert!(first.contains("[12:00]"), "{first:?}");
315        assert!(!second.contains("[12:00]"), "{second:?}");
316        assert!(second.contains("two"));
317    }
318
319    #[test]
320    fn warn_uses_pythons_level_name() {
321        let out = console().render_to_string(&LogRecord::new(LogLevel::Warn, "low disk"));
322        assert!(out.contains("\x1b[33mWARNING \x1b[0m"), "{out:?}");
323        assert!(out.contains("low disk"));
324    }
325}