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