1use std::io::Write;
28use std::sync::{Arc, Mutex};
29use std::time::{Duration, SystemTime, UNIX_EPOCH};
30
31use rich::{
32 Console, ConsoleOptions, Highlighter, LogRender, ReprHighlighter, Segment, Table, Text,
33};
34
35use crate::event::{Message, Severity, SpanContext, SpanEvent, StructuredEvent};
36use crate::hyperlink::Hyperlinker;
37use crate::live::{LiveCoordinator, LiveError};
38
39pub const KEYWORDS: &[&str] = &[
41 "GET", "POST", "HEAD", "PUT", "DELETE", "OPTIONS", "TRACE", "PATCH",
42];
43
44type TimeFormat = Box<dyn Fn() -> String + Send + Sync>;
45type Output = Box<dyn Fn(&[Segment]) -> std::io::Result<()> + Send + Sync>;
46
47#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
49pub enum SpanView {
50 #[default]
52 Inline,
53 Tree,
57 Hidden,
59}
60
61pub struct RichHandler {
63 console: Mutex<Console>,
64 render: Mutex<LogRender>,
65 highlighter: Option<Box<dyn Highlighter + Send + Sync>>,
66 markup: bool,
67 keywords: Vec<String>,
68 enable_link_path: bool,
69 time_format: TimeFormat,
70 span_view: SpanView,
71 hyperlinker: Option<Hyperlinker>,
72 output: Option<Output>,
73}
74
75impl RichHandler {
76 pub fn new(console: Console) -> Self {
80 RichHandler {
81 console: Mutex::new(console),
82 render: Mutex::new(LogRender::new().show_level(true)),
83 highlighter: Some(Box::new(ReprHighlighter::new())),
84 markup: false,
85 keywords: KEYWORDS.iter().map(|word| word.to_string()).collect(),
86 enable_link_path: true,
87 time_format: Box::new(utc_time),
88 span_view: SpanView::Inline,
89 hyperlinker: None,
90 output: None,
91 }
92 }
93
94 #[must_use]
96 pub fn span_view(mut self, view: SpanView) -> Self {
97 self.span_view = view;
98 self
99 }
100
101 #[must_use]
105 pub fn hyperlinker(mut self, hyperlinker: Hyperlinker) -> Self {
106 self.hyperlinker = Some(hyperlinker);
107 self
108 }
109
110 #[must_use]
116 pub fn live<W: Write + Send + 'static>(mut self, live: Arc<Mutex<LiveCoordinator<W>>>) -> Self {
117 self.output = Some(Box::new(move |segments| {
118 let segments = neutralise(segments);
119 live.lock()
120 .unwrap_or_else(|e| e.into_inner())
121 .print(&segments)
122 .map_err(|error| match error {
123 LiveError::Io(error) => error,
124 other => std::io::Error::other(other.to_string()),
125 })
126 }));
127 self
128 }
129
130 fn map_render(self, f: impl FnOnce(LogRender) -> LogRender) -> Self {
131 let render = self.render.into_inner().unwrap_or_else(|e| e.into_inner());
132 RichHandler {
133 render: Mutex::new(f(render)),
134 ..self
135 }
136 }
137
138 #[must_use]
140 pub fn show_time(self, show: bool) -> Self {
141 self.map_render(|render| render.show_time(show))
142 }
143
144 #[must_use]
146 pub fn show_level(self, show: bool) -> Self {
147 self.map_render(|render| render.show_level(show))
148 }
149
150 #[must_use]
152 pub fn show_path(self, show: bool) -> Self {
153 self.map_render(|render| render.show_path(show))
154 }
155
156 #[must_use]
159 pub fn omit_repeated_times(self, omit: bool) -> Self {
160 self.map_render(|render| render.omit_repeated_times(omit))
161 }
162
163 #[must_use]
166 pub fn level_width(self, width: Option<usize>) -> Self {
167 self.map_render(|render| render.level_width(width))
168 }
169
170 #[must_use]
172 pub fn markup(mut self, markup: bool) -> Self {
173 self.markup = markup;
174 self
175 }
176
177 #[must_use]
179 pub fn highlighter(mut self, highlighter: Option<Box<dyn Highlighter + Send + Sync>>) -> Self {
180 self.highlighter = highlighter;
181 self
182 }
183
184 #[must_use]
186 pub fn keywords<I, S>(mut self, keywords: I) -> Self
187 where
188 I: IntoIterator<Item = S>,
189 S: Into<String>,
190 {
191 self.keywords = keywords.into_iter().map(Into::into).collect();
192 self
193 }
194
195 #[must_use]
197 pub fn enable_link_path(mut self, enable: bool) -> Self {
198 self.enable_link_path = enable;
199 self
200 }
201
202 #[must_use]
205 pub fn time_format(mut self, format: impl Fn() -> String + Send + Sync + 'static) -> Self {
206 self.time_format = Box::new(format);
207 self
208 }
209
210 pub fn level_text(severity: Severity) -> Text {
213 let name = match severity {
214 Severity::Trace => {
215 return Text::styled(format!("{:<8}", "TRACE"), "logging.level.notset");
216 }
217 Severity::Debug => "DEBUG",
218 Severity::Info => "INFO",
219 Severity::Warn => "WARNING",
220 Severity::Error => "ERROR",
221 Severity::Fatal => "CRITICAL",
222 };
223 rich::level_text(name)
224 }
225
226 pub fn render_message(&self, event: &StructuredEvent) -> Text {
229 let ascii = self
230 .console
231 .lock()
232 .unwrap_or_else(|e| e.into_inner())
233 .ascii_only();
234 self.message_text(event, ascii)
235 }
236
237 fn message_text(&self, event: &StructuredEvent, ascii: bool) -> Text {
240 let spans = event.span_context();
241 let mut text = match event.span_marker() {
242 Some(marker) => self.span_line(event, marker, ascii),
243 None => {
244 let mut text = self.prefix(spans, ascii);
245 text = text.append_text(&match &event.message {
246 Message::Literal(message) if !self.markup => Text::new(message.clone()),
247 Message::Literal(markup) | Message::Markup(markup) => {
248 Text::from_markup(markup).unwrap_or_else(|_| Text::new(markup.clone()))
249 }
250 });
251 for (key, value) in &event.fields {
252 text.append(&format!(" {key}={}", value.format(false, 0)), None);
253 }
254 text
255 }
256 };
257 if let Some(highlighter) = &self.highlighter {
258 highlighter.highlight(&mut text);
259 }
260 if !self.keywords.is_empty() {
261 let words: Vec<&str> = self.keywords.iter().map(String::as_str).collect();
262 let _ = text.highlight_words(&words, "logging.keyword", true);
263 }
264 text
265 }
266
267 fn prefix(&self, spans: &[SpanContext], ascii: bool) -> Text {
270 let mut text = Text::new("");
271 match self.span_view {
272 SpanView::Hidden => {}
273 SpanView::Tree => text.append(&guide(ascii).repeat(spans.len()), Some("dim".into())),
274 SpanView::Inline if spans.is_empty() => {}
275 SpanView::Inline => {
276 for (index, span) in spans.iter().enumerate() {
277 if index > 0 {
278 text.append(":", Some("dim".into()));
279 }
280 text = text.append_text(&span_label(span, "{", "}"));
281 }
282 text.append(": ", Some("dim".into()));
283 }
284 }
285 text
286 }
287
288 fn span_line(&self, event: &StructuredEvent, marker: SpanEvent, ascii: bool) -> Text {
291 let name = match &event.message {
292 Message::Literal(name) | Message::Markup(name) => name.clone(),
293 };
294 let span = SpanContext {
295 name,
296 fields: event.fields.clone(),
297 };
298 let parents = event.span_context();
299 let elapsed = match marker {
300 SpanEvent::Open => None,
301 SpanEvent::Close { elapsed } => Some(elapsed),
302 };
303 let mut text = Text::new("");
304 match self.span_view {
305 SpanView::Tree => {
306 text.append(&guide(ascii).repeat(parents.len()), Some("dim".into()));
307 let corner = match (elapsed.is_some(), ascii) {
308 (false, false) => "┌ ",
309 (true, false) => "└ ",
310 (false, true) => "+ ",
311 (true, true) => "` ",
312 };
313 text.append(corner, Some("dim".into()));
314 if elapsed.is_some() {
316 text = text.append_text(&Text::styled(span.name.clone(), "bold"));
317 } else {
318 text = text.append_text(&span_label(&span, " ", ""));
319 }
320 }
321 SpanView::Inline | SpanView::Hidden => {
322 if self.span_view == SpanView::Inline {
323 for parent in parents {
324 text = text.append_text(&span_label(parent, "{", "}"));
325 text.append(":", Some("dim".into()));
326 }
327 }
328 text = text.append_text(&span_label(&span, "{", "}"));
329 text.append(
330 if elapsed.is_some() {
331 " closed"
332 } else {
333 " opened"
334 },
335 Some("dim".into()),
336 );
337 }
338 }
339 if let Some(elapsed) = elapsed {
340 text.append(" ", None);
341 text.append(&format_elapsed(elapsed), Some("dim".into()));
342 }
343 text
344 }
345
346 pub fn render(&self, event: &StructuredEvent) -> Table {
348 let console = self.console.lock().unwrap_or_else(|e| e.into_inner());
349 self.render_with(&console, event)
350 }
351
352 fn render_with(&self, console: &Console, event: &StructuredEvent) -> Table {
353 let context = &event.context;
354 let time = context
355 .timestamp
356 .clone()
357 .unwrap_or_else(|| (self.time_format)());
358 let level = Self::level_text(context.severity.unwrap_or(Severity::Info));
359 let source = context.source.as_ref();
360 let full_path = source.map(|source| source.path.as_str());
361 let name = full_path.map(|path| path.rsplit(['/', '\\']).next().unwrap_or(path));
362 let render = self.render.lock().unwrap_or_else(|e| e.into_inner());
363 render.render(
364 console,
365 self.message_text(event, console.ascii_only()),
366 Some(Text::new(time)),
367 level,
368 name,
369 source.and_then(|source| u32::try_from(source.line).ok()),
370 full_path.filter(|_| self.enable_link_path),
371 )
372 }
373
374 pub fn emit_event(&self, event: &StructuredEvent) {
376 let _ = self.try_emit(event);
377 }
378
379 fn try_emit(&self, event: &StructuredEvent) -> std::io::Result<()> {
380 let console = self.console.lock().unwrap_or_else(|e| e.into_inner());
381 let table = self.render_with(&console, event);
382 if self.hyperlinker.is_none() && self.output.is_none() {
383 console.print(&table);
384 return Ok(());
385 }
386 let mut lines = console.render_lines(&table, &console.options(), false);
387 if let (Some(hyperlinker), Some(source)) = (&self.hyperlinker, &event.context.source) {
388 let line = Some(source.line).filter(|&line| line > 0);
389 let url = hyperlinker.file_url(&source.path, line, None);
390 relink(&mut lines, &source.path, url.as_deref());
391 }
392 match &self.output {
393 Some(output) => output(&join_lines(lines)),
394 None => {
395 console.print(&Lines(lines));
396 Ok(())
397 }
398 }
399 }
400}
401
402fn neutralise(segments: &[Segment]) -> Vec<Segment> {
407 segments
408 .iter()
409 .filter(|segment| !segment.control)
410 .map(|segment| {
411 let mut segment = segment.clone();
412 if segment
413 .text
414 .chars()
415 .any(|c| c.is_control() && c != '\n' && c != '\t')
416 {
417 segment.text = crate::sanitize_terminal_controls(&segment.text).into();
418 }
419 if let Some(style) = &segment.style
420 && let Some(link) = style
421 .link()
422 .filter(|link| link.chars().any(char::is_control))
423 {
424 use std::fmt::Write as _;
425 let mut encoded = String::with_capacity(link.len());
426 for c in link.chars() {
427 if c.is_control() {
428 let mut bytes = [0; 4];
429 for byte in c.encode_utf8(&mut bytes).bytes() {
430 let _ = write!(encoded, "%{byte:02X}");
431 }
432 } else {
433 encoded.push(c);
434 }
435 }
436 segment.style = Some(style.update_link(Some(encoded)));
437 }
438 segment
439 })
440 .collect()
441}
442
443fn guide(ascii: bool) -> &'static str {
445 if ascii { "| " } else { "│ " }
446}
447
448fn span_label(span: &SpanContext, open: &str, close: &str) -> Text {
451 let mut text = Text::styled(span.name.clone(), "bold");
452 if !span.fields.is_empty() {
453 let fields: Vec<String> = span
454 .fields
455 .iter()
456 .map(|(key, value)| format!("{key}={}", value.format(false, 0)))
457 .collect();
458 text.append(open, Some("dim".into()));
459 text.append(&fields.join(" "), None);
460 text.append(close, Some("dim".into()));
461 }
462 text
463}
464
465fn format_elapsed(elapsed: Duration) -> String {
467 let micros = elapsed.as_secs_f64() * 1e6;
468 if micros < 1000.0 {
469 format!("{micros:.0}µs")
470 } else if micros < 1e6 {
471 format!("{:.2}ms", micros / 1e3)
472 } else {
473 format!("{:.2}s", micros / 1e6)
474 }
475}
476
477fn relink(lines: &mut [Vec<Segment>], path: &str, url: Option<&str>) {
480 let bare = format!("file://{path}");
481 for segment in lines.iter_mut().flatten() {
482 let Some(style) = &segment.style else {
483 continue;
484 };
485 let linked = style
486 .link()
487 .is_some_and(|link| link == bare || link.starts_with(&format!("{bare}#")));
488 if linked {
489 segment.style = Some(style.update_link(url.map(str::to_owned)));
490 }
491 }
492}
493
494fn join_lines(lines: Vec<Vec<Segment>>) -> Vec<Segment> {
495 let mut segments = Vec::new();
496 for (index, line) in lines.into_iter().enumerate() {
497 if index > 0 {
498 segments.push(Segment::line());
499 }
500 segments.extend(line);
501 }
502 segments
503}
504
505struct Lines(Vec<Vec<Segment>>);
507
508impl rich::Renderable for Lines {
509 fn rich_render(&self, _: &Console, _: &ConsoleOptions) -> Vec<Segment> {
510 join_lines(self.0.clone())
511 }
512}
513
514#[cfg(any(feature = "log", feature = "tracing"))]
515impl crate::adapters::EventSink for RichHandler {
516 fn emit(&self, event: StructuredEvent) -> std::io::Result<()> {
517 self.emit_event(&event);
518 Ok(())
519 }
520}
521
522fn utc_time() -> String {
524 let secs = SystemTime::now()
525 .duration_since(UNIX_EPOCH)
526 .map_or(0, |elapsed| elapsed.as_secs());
527 let day = secs % 86_400;
528 format!("[{:02}:{:02}:{:02}]", day / 3600, day / 60 % 60, day % 60)
529}
530
531#[cfg(test)]
532mod tests {
533 use super::*;
534 use crate::event::{EventContext, SourceLocation, Value};
535
536 fn handler() -> RichHandler {
537 let console = Console::builder()
538 .force_terminal(true)
539 .color_system(Some(rich::ColorSystem::Truecolor))
540 .width(60)
541 .build();
542 RichHandler::new(console).time_format(|| "[12:00:00]".into())
543 }
544
545 fn event(message: &str, severity: Severity) -> StructuredEvent {
546 StructuredEvent::new(Message::Literal(message.into())).context(EventContext {
547 severity: Some(severity),
548 source: Some(SourceLocation {
549 path: "src/server/main.rs".into(),
550 line: 42,
551 column: None,
552 }),
553 ..Default::default()
554 })
555 }
556
557 #[test]
558 fn renders_time_level_message_and_file_name() {
559 let handler = handler().enable_link_path(false);
560 let console = handler.console.lock().unwrap();
561 let plain = console
562 .render_to_string(&handler.render_with(&console, &event("GET /index", Severity::Warn)));
563 assert!(plain.contains("[12:00:00]"), "{plain:?}");
564 assert!(plain.contains("WARNING"), "{plain:?}");
565 assert!(plain.contains("main.rs:42"), "{plain:?}");
566 assert!(!plain.contains("src/server"), "{plain:?}");
567 assert!(plain.contains("\x1b[1;33mGET\x1b[0m"), "{plain:?}");
569 }
570
571 #[test]
572 fn links_the_full_path_and_appends_fields() {
573 let handler = handler();
574 let console = handler.console.lock().unwrap();
575 let event = event("ready", Severity::Info).field("count", Value::Integer(3));
576 let out = console.render_to_string(&handler.render_with(&console, &event));
577 assert!(out.contains("file://src/server/main.rs#42"), "{out:?}");
578 assert!(out.contains("\x1b[33mcount\x1b[0m=\x1b[1;36m3"), "{out:?}");
579 }
580
581 #[test]
582 fn fatal_is_critical_and_trace_is_notset() {
583 assert_eq!(RichHandler::level_text(Severity::Fatal).plain(), "CRITICAL");
584 let trace = RichHandler::level_text(Severity::Trace);
585 assert_eq!(trace.plain(), "TRACE ");
586 }
587}