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/// Format `time` as Python's `datetime.strftime(format)` formats a naive
236/// datetime holding the same UTC wall-clock time, in the C locale: the time
237/// column of `Console.log` (upstream's default `"[%X]"`).
238///
239/// The C89 directives and glibc's common extensions are supported (`%a %A
240/// %b %B %c %C %d %D %e %f %F %h %H %I %j %m %M %n %p %r %R %S %t %T %u %U
241/// %w %W %x %X %y %Y %z %Z %%`); `%z` and `%Z` are empty, as for a naive
242/// datetime, and any other directive is written as it is, as glibc does.
243pub(crate) fn strftime(time: std::time::SystemTime, format: &str) -> String {
244    let (seconds, micros) = match time.duration_since(std::time::UNIX_EPOCH) {
245        Ok(since) => (since.as_secs() as i64, since.subsec_micros()),
246        Err(before) => {
247            let before = before.duration();
248            let mut seconds = -(before.as_secs() as i64);
249            let mut micros = before.subsec_micros();
250            if micros > 0 {
251                seconds -= 1;
252                micros = 1_000_000 - micros;
253            }
254            (seconds, micros)
255        }
256    };
257    let days = seconds.div_euclid(86_400);
258    let of_day = seconds.rem_euclid(86_400);
259    let (year, month, day) = civil_from_days(days);
260    let (hour, minute, second) = (of_day / 3600, of_day / 60 % 60, of_day % 60);
261    // 1970-01-01 was a Thursday; 0 is Sunday, as `%w`.
262    let weekday = (days + 4).rem_euclid(7);
263    let year_day = days - days_from_civil(year, 1, 1);
264    let hour12 = if hour % 12 == 0 { 12 } else { hour % 12 };
265    const DAYS: [&str; 7] = [
266        "Sunday",
267        "Monday",
268        "Tuesday",
269        "Wednesday",
270        "Thursday",
271        "Friday",
272        "Saturday",
273    ];
274    const MONTHS: [&str; 12] = [
275        "January",
276        "February",
277        "March",
278        "April",
279        "May",
280        "June",
281        "July",
282        "August",
283        "September",
284        "October",
285        "November",
286        "December",
287    ];
288    let day_name = DAYS[weekday as usize];
289    let month_name = MONTHS[(month - 1) as usize];
290    let mut out = String::new();
291    let mut chars = format.chars();
292    while let Some(ch) = chars.next() {
293        if ch != '%' {
294            out.push(ch);
295            continue;
296        }
297        let Some(directive) = chars.next() else {
298            out.push('%');
299            break;
300        };
301        let piece = match directive {
302            'a' => day_name[..3].to_string(),
303            'A' => day_name.to_string(),
304            'b' | 'h' => month_name[..3].to_string(),
305            'B' => month_name.to_string(),
306            'c' => format!(
307                "{} {} {day:>2} {hour:02}:{minute:02}:{second:02} {year}",
308                &day_name[..3],
309                &month_name[..3]
310            ),
311            'C' => format!("{:02}", year.div_euclid(100)),
312            'd' => format!("{day:02}"),
313            'D' | 'x' => format!("{month:02}/{day:02}/{:02}", year.rem_euclid(100)),
314            'e' => format!("{day:>2}"),
315            'f' => format!("{micros:06}"),
316            'F' => format!("{year}-{month:02}-{day:02}"),
317            'H' => format!("{hour:02}"),
318            'I' => format!("{hour12:02}"),
319            'j' => format!("{:03}", year_day + 1),
320            'm' => format!("{month:02}"),
321            'M' => format!("{minute:02}"),
322            'n' => "\n".to_string(),
323            'p' => if hour < 12 { "AM" } else { "PM" }.to_string(),
324            'r' => format!(
325                "{hour12:02}:{minute:02}:{second:02} {}",
326                if hour < 12 { "AM" } else { "PM" }
327            ),
328            'R' => format!("{hour:02}:{minute:02}"),
329            'S' => format!("{second:02}"),
330            't' => "\t".to_string(),
331            'T' | 'X' => format!("{hour:02}:{minute:02}:{second:02}"),
332            'u' => (if weekday == 0 { 7 } else { weekday }).to_string(),
333            'U' => format!("{:02}", (year_day + 7 - weekday) / 7),
334            'w' => weekday.to_string(),
335            'W' => format!("{:02}", (year_day + 7 - (weekday + 6) % 7) / 7),
336            'y' => format!("{:02}", year.rem_euclid(100)),
337            'Y' => year.to_string(),
338            'z' | 'Z' => String::new(),
339            '%' => "%".to_string(),
340            other => format!("%{other}"),
341        };
342        out.push_str(&piece);
343    }
344    out
345}
346
347/// The proleptic Gregorian `(year, month, day)` of a day count from
348/// 1970-01-01 (Howard Hinnant's `civil_from_days`).
349fn civil_from_days(days: i64) -> (i64, i64, i64) {
350    let z = days + 719_468;
351    let era = z.div_euclid(146_097);
352    let doe = z.rem_euclid(146_097);
353    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
354    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
355    let mp = (5 * doy + 2) / 153;
356    let day = doy - (153 * mp + 2) / 5 + 1;
357    let month = if mp < 10 { mp + 3 } else { mp - 9 };
358    let year = yoe + era * 400 + i64::from(month <= 2);
359    (year, month, day)
360}
361
362/// The day count from 1970-01-01 of a proleptic Gregorian date (Howard
363/// Hinnant's `days_from_civil`).
364fn days_from_civil(year: i64, month: i64, day: i64) -> i64 {
365    let year = if month <= 2 { year - 1 } else { year };
366    let era = year.div_euclid(400);
367    let yoe = year.rem_euclid(400);
368    let mp = if month > 2 { month - 3 } else { month + 9 };
369    let doy = (153 * mp + 2) / 5 + day - 1;
370    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
371    era * 146_097 + doe - 719_468
372}
373
374/// Log severity, mirroring the `log` crate's five levels.
375#[derive(Debug, Clone, Copy, PartialEq, Eq)]
376pub enum LogLevel {
377    Trace,
378    Debug,
379    Info,
380    Warn,
381    Error,
382}
383
384impl LogLevel {
385    /// The level name as Python's `logging` spells it (`WARNING`, not `WARN`);
386    /// `TRACE`, which Python lacks, uses `logging.level.notset`.
387    pub fn name(self) -> &'static str {
388        match self {
389            LogLevel::Trace => "TRACE",
390            LogLevel::Debug => "DEBUG",
391            LogLevel::Info => "INFO",
392            LogLevel::Warn => "WARNING",
393            LogLevel::Error => "ERROR",
394        }
395    }
396
397    /// The styled level column for this level.
398    pub fn text(self) -> Text {
399        match self {
400            LogLevel::Trace => Text::styled(format!("{:<8}", "TRACE"), "logging.level.notset"),
401            level => level_text(level.name()),
402        }
403    }
404}
405
406/// One log record, rendered through a fresh [`LogRender`] with the level
407/// shown. A convenience for printing a single record; a stream of records
408/// should share one [`LogRender`] so repeated times are omitted.
409pub struct LogRecord {
410    level: LogLevel,
411    message: String,
412    time: Option<String>,
413    path: Option<String>,
414    line_no: Option<u32>,
415}
416
417impl LogRecord {
418    /// A record at `level` with `message`, which is plain text.
419    pub fn new(level: LogLevel, message: impl Into<String>) -> Self {
420        LogRecord {
421            level,
422            message: message.into(),
423            time: None,
424            path: None,
425            line_no: None,
426        }
427    }
428
429    /// The formatted time for the time column.
430    pub fn time(mut self, time: impl Into<String>) -> Self {
431        self.time = Some(time.into());
432        self
433    }
434
435    /// The source path for the path column.
436    pub fn path(mut self, path: impl Into<String>) -> Self {
437        self.path = Some(path.into());
438        self
439    }
440
441    /// The source line, shown after the path.
442    pub fn line(mut self, line: u32) -> Self {
443        self.line_no = Some(line);
444        self
445    }
446
447    fn table(&self, console: &Console) -> Table {
448        LogRender::new()
449            .show_level(true)
450            .show_time(self.time.is_some())
451            .render(
452                console,
453                Text::new(self.message.clone()),
454                self.time.as_deref().map(Text::new),
455                self.level.text(),
456                self.path.as_deref(),
457                self.line_no,
458                None,
459            )
460    }
461}
462
463impl Renderable for LogRecord {
464    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
465        self.table(console).rich_render(console, options)
466    }
467
468    fn measure(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
469        self.table(console).measure(console, options)
470    }
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476    use crate::color::ColorSystem;
477
478    fn console() -> Console {
479        Console::builder()
480            .force_terminal(true)
481            .color_system(Some(ColorSystem::Truecolor))
482            .width(40)
483            .highlight(false)
484            .build()
485    }
486
487    #[test]
488    fn a_repeated_time_is_blanked() {
489        let console = console();
490        let render = LogRender::new();
491        let first = console.render_to_string(&render.render(
492            &console,
493            Text::new("one"),
494            Some(Text::new("[12:00]")),
495            Text::new(""),
496            None,
497            None,
498            None,
499        ));
500        let second = console.render_to_string(&render.render(
501            &console,
502            Text::new("two"),
503            Some(Text::new("[12:00]")),
504            Text::new(""),
505            None,
506            None,
507            None,
508        ));
509        assert!(first.contains("[12:00]"), "{first:?}");
510        assert!(!second.contains("[12:00]"), "{second:?}");
511        assert!(second.contains("two"));
512    }
513
514    #[test]
515    fn warn_uses_pythons_level_name() {
516        let out = console().render_to_string(&LogRecord::new(LogLevel::Warn, "low disk"));
517        assert!(out.contains("\x1b[33mWARNING \x1b[0m"), "{out:?}");
518        assert!(out.contains("low disk"));
519    }
520}