1use std::fmt;
2use std::sync::{Arc, Mutex, RwLock};
3use std::time::SystemTime;
4
5use humantime::format_rfc3339_seconds;
6use once_cell::sync::Lazy;
7use ratatui::{
8 style::{Color, Modifier, Style},
9 text::{Line, Span, Text},
10};
11use syntect::easy::HighlightLines;
12use syntect::highlighting::Theme;
13use syntect::parsing::SyntaxReference;
14use tokio::sync::mpsc::UnboundedSender;
15use tracing::{Event, Level, Subscriber, field::Visit};
16use tracing_subscriber::layer::{Context, Layer};
17use tracing_subscriber::registry::LookupSpan;
18
19use crate::ui::syntax_highlight::{
20 default_theme_name, find_syntax_by_extension, find_syntax_by_name, find_syntax_plain_text,
21 load_theme, syntax_set,
22};
23
24#[derive(Debug, Clone)]
25pub struct LogEntry {
26 pub formatted: Arc<str>,
27 pub timestamp: Arc<str>,
28 pub level: Level,
29 pub target: Arc<str>,
30 pub message: Arc<str>,
31}
32
33#[derive(Default)]
34struct LogForwarder {
35 sender: Mutex<Option<UnboundedSender<LogEntry>>>,
36}
37
38impl LogForwarder {
39 fn set_sender(&self, sender: UnboundedSender<LogEntry>) {
40 match self.sender.lock() {
41 Ok(mut guard) => {
42 *guard = Some(sender);
43 }
44 Err(err) => {
45 tracing::warn!("failed to set TUI log sender; lock poisoned: {err}");
46 }
47 }
48 }
49
50 fn clear_sender(&self) {
51 match self.sender.lock() {
52 Ok(mut guard) => {
53 *guard = None;
54 }
55 Err(err) => {
56 tracing::warn!("failed to clear TUI log sender; lock poisoned: {err}");
57 }
58 }
59 }
60
61 fn send(&self, entry: LogEntry) {
62 match self.sender.lock() {
63 Ok(guard) => {
64 if let Some(sender) = guard.as_ref() {
65 let _ = sender.send(entry);
66 }
67 }
68 Err(err) => {
69 tracing::warn!("failed to forward TUI log entry; lock poisoned: {err}");
70 }
71 }
72 }
73}
74
75static LOG_FORWARDER: Lazy<Arc<LogForwarder>> = Lazy::new(|| Arc::new(LogForwarder::default()));
76
77static LOG_THEME_NAME: Lazy<RwLock<Option<String>>> = Lazy::new(|| RwLock::new(None));
78static LOG_THEME_CACHE: Lazy<RwLock<Option<(String, Theme)>>> = Lazy::new(|| RwLock::new(None));
79
80#[derive(Default)]
81struct FieldVisitor {
82 message: Option<String>,
83 extras: Vec<(String, String)>,
84}
85
86impl Visit for FieldVisitor {
87 fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn fmt::Debug) {
88 let rendered = format!("{:?}", value);
89 if field.name() == "message" {
90 self.message = Some(rendered);
91 } else {
92 self.extras.push((field.name().to_string(), rendered));
93 }
94 }
95
96 fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
97 if field.name() == "message" {
98 self.message = Some(value.to_string());
99 } else {
100 self.extras
101 .push((field.name().to_string(), value.to_string()));
102 }
103 }
104}
105
106pub struct TuiLogLayer {
107 forwarder: Arc<LogForwarder>,
108}
109
110impl TuiLogLayer {
111 pub fn new() -> Self {
112 Self {
113 forwarder: LOG_FORWARDER.clone(),
114 }
115 }
116}
117
118impl Default for TuiLogLayer {
119 fn default() -> Self {
120 Self::new()
121 }
122}
123
124impl TuiLogLayer {
125 pub fn set_sender(sender: UnboundedSender<LogEntry>) {
126 LOG_FORWARDER.set_sender(sender);
127 }
128
129 pub fn clear_sender() {
130 LOG_FORWARDER.clear_sender();
131 }
132}
133
134impl<S> Layer<S> for TuiLogLayer
135where
136 S: Subscriber + for<'span> LookupSpan<'span>,
137{
138 fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
139 let level = *event.metadata().level();
140
141 if level == tracing::Level::TRACE {
143 return;
144 }
145
146 if level == tracing::Level::DEBUG && !crate::ui::tui::panic_hook::is_debug_mode() {
148 return;
149 }
150
151 let mut visitor = FieldVisitor::default();
152 event.record(&mut visitor);
153
154 let message = visitor
155 .message
156 .unwrap_or_else(|| "(no message)".to_string());
157 let extras = if visitor.extras.is_empty() {
158 String::new()
159 } else {
160 let rendered = visitor
161 .extras
162 .into_iter()
163 .map(|(key, value)| format!("{key}={value}"))
164 .collect::<Vec<_>>()
165 .join(" ");
166 format!(" {rendered}")
167 };
168
169 let combined_message = format!("{}{}", message, extras);
171 if should_filter_log_message(&combined_message) {
172 return;
173 }
174
175 let timestamp = format_rfc3339_seconds(SystemTime::now()).to_string();
176 let line = format!(
177 "{} {:<5} {} {}{}",
178 timestamp,
179 event.metadata().level(),
180 event.metadata().target(),
181 message,
182 extras
183 );
184
185 let log_target = Arc::from(event.metadata().target().to_string().into_boxed_str());
186 let message_with_extras = Arc::from(format!("{message}{extras}").into_boxed_str());
187 self.forwarder.send(LogEntry {
188 formatted: Arc::from(line.into_boxed_str()),
189 timestamp: Arc::from(timestamp.into_boxed_str()),
190 level: *event.metadata().level(),
191 target: log_target,
192 message: message_with_extras,
193 });
194 }
195}
196
197fn should_filter_log_message(message: &str) -> bool {
199 let lower_message = message.to_lowercase();
200
201 let malloc_filters = [
203 "mallocstacklogging",
204 "malscollogging",
205 "no such file or directory",
206 "can't turn off malloc stack logging",
207 "could not tag msl-related memory",
208 "those pages will be included in process footprint",
209 "process is not in a debuggable environment",
210 "unsetting mallocstackloggingdirectory environment variable",
211 ];
212
213 malloc_filters
214 .iter()
215 .any(|&filter| lower_message.contains(&filter.to_lowercase()))
216}
217
218pub fn make_tui_log_layer() -> TuiLogLayer {
219 TuiLogLayer::new()
220}
221
222pub fn register_tui_log_sender(sender: UnboundedSender<LogEntry>) {
223 TuiLogLayer::set_sender(sender);
224}
225
226pub fn clear_tui_log_sender() {
227 TuiLogLayer::clear_sender();
228}
229
230pub fn set_log_theme_name(theme: Option<String>) {
231 let Ok(mut slot) = LOG_THEME_NAME.write() else {
232 tracing::warn!("failed to set TUI log theme name; theme lock poisoned");
233 return;
234 };
235 *slot = theme
236 .map(|t| t.trim().to_string())
237 .filter(|t| !t.is_empty());
238 drop(slot);
239 if let Ok(mut cache) = LOG_THEME_CACHE.write() {
240 *cache = None;
241 } else {
242 tracing::warn!("failed to clear TUI log theme cache; cache lock poisoned");
243 }
244}
245
246fn theme_for_current_config() -> Theme {
247 let theme_name = {
248 let Ok(slot) = LOG_THEME_NAME.read() else {
249 tracing::warn!("failed to read TUI log theme name; falling back to default theme");
250 return load_theme(&default_theme_name(), true);
251 };
252 slot.clone()
253 };
254 let resolved_name = theme_name.clone().unwrap_or_else(default_theme_name);
255 {
256 if let Ok(cache) = LOG_THEME_CACHE.read()
257 && let Some((cached_name, cached)) = &*cache
258 && *cached_name == resolved_name
259 {
260 return cached.clone();
261 }
262 }
263
264 let theme = load_theme(&resolved_name, true);
265 if let Ok(mut cache) = LOG_THEME_CACHE.write() {
266 *cache = Some((resolved_name, theme.clone()));
267 }
268 theme
269}
270
271fn syntect_to_ratatui_style(style: syntect::highlighting::Style) -> Style {
272 let fg = style.foreground;
273 Style::default().fg(Color::Rgb(fg.r, fg.g, fg.b))
274}
275
276fn highlight_lines_to_text<'a>(
277 lines: impl Iterator<Item = &'a str>,
278 syntax: &SyntaxReference,
279) -> Text<'static> {
280 let theme = theme_for_current_config();
281 let ss = syntax_set();
282 let mut highlighter = HighlightLines::new(syntax, &theme);
283 let mut result_lines = Vec::new();
284
285 for line in lines {
286 match highlighter.highlight_line(line, ss) {
287 Ok(ranges) => {
288 let spans: Vec<Span<'static>> = ranges
289 .into_iter()
290 .map(|(style, text)| {
291 Span::styled(text.to_owned(), syntect_to_ratatui_style(style))
292 })
293 .collect();
294 result_lines.push(Line::from(spans));
295 }
296 Err(_) => {
297 result_lines.push(Line::raw(line.to_owned()));
298 }
299 }
300 }
301
302 Text::from(result_lines)
303}
304
305fn log_level_style(level: &Level) -> Style {
306 match *level {
307 Level::ERROR => Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
308 Level::WARN => Style::default()
309 .fg(Color::Yellow)
310 .add_modifier(Modifier::BOLD),
311 Level::INFO => Style::default().fg(Color::Green),
312 Level::DEBUG => Style::default().fg(Color::Blue),
313 Level::TRACE => Style::default().fg(Color::DarkGray),
314 }
315}
316
317fn log_prefix(entry: &LogEntry) -> Vec<Span<'static>> {
318 let timestamp_style = Style::default().fg(Color::DarkGray);
319 vec![
320 Span::styled(format!("[{}]", entry.timestamp), timestamp_style),
321 Span::raw(" "),
322 Span::styled(format!("{:<5}", entry.level), log_level_style(&entry.level)),
323 Span::raw(" "),
324 Span::styled(entry.target.to_string(), Style::default().fg(Color::Gray)),
325 Span::raw(" "),
326 ]
327}
328
329fn prepend_metadata(text: &mut Text<'static>, entry: &LogEntry) {
330 let mut prefix = log_prefix(entry);
331 if let Some(first) = text.lines.first_mut() {
332 let mut merged = Vec::with_capacity(prefix.len() + first.spans.len());
333 merged.append(&mut prefix);
334 merged.append(&mut first.spans);
335 first.spans = merged;
336 } else {
337 text.lines.push(Line::from(prefix));
338 }
339}
340
341fn select_syntax(message: &str) -> &'static SyntaxReference {
342 let trimmed = message.trim_start();
343 if !trimmed.is_empty() {
344 if (trimmed.starts_with('{') || trimmed.starts_with('['))
345 && let Some(json) =
346 find_syntax_by_name("JSON").or_else(|| find_syntax_by_extension("json"))
347 {
348 return json;
349 }
350
351 if (trimmed.contains('$') || trimmed.contains(';'))
352 && let Some(shell) =
353 find_syntax_by_name("Bash").or_else(|| find_syntax_by_extension("sh"))
354 {
355 return shell;
356 }
357 }
358
359 find_syntax_by_name("Rust")
360 .or_else(|| find_syntax_by_extension("rs"))
361 .unwrap_or_else(find_syntax_plain_text)
362}
363
364pub fn highlight_log_entry(entry: &LogEntry) -> Text<'static> {
365 let ss = syntax_set();
366 if ss.syntaxes().is_empty() {
367 let mut text = Text::raw(entry.formatted.as_ref().to_string());
368 prepend_metadata(&mut text, entry);
369 return text;
370 }
371
372 let syntax = select_syntax(entry.message.as_ref());
373 let mut highlighted = highlight_lines_to_text(entry.message.lines(), syntax);
374 prepend_metadata(&mut highlighted, entry);
375 highlighted
376}