use std::fmt;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, RwLock};
use std::time::SystemTime;
use humantime::format_rfc3339_seconds;
use once_cell::sync::Lazy;
use ratatui::{
style::{Color, Modifier, Style},
text::{Line, Span, Text},
};
use syntect::easy::HighlightLines;
use syntect::highlighting::Theme;
use syntect::parsing::SyntaxReference;
use tokio::sync::mpsc::UnboundedSender;
use tracing::{Event, Level, Subscriber, field::Visit};
use tracing_subscriber::layer::{Context, Layer};
use tracing_subscriber::registry::LookupSpan;
use crate::ui::syntax_highlight::{
default_theme_name, find_syntax_by_extension, find_syntax_by_name, find_syntax_plain_text,
load_theme, syntax_set,
};
#[derive(Debug, Clone)]
pub struct LogEntry {
pub formatted: Arc<str>,
pub timestamp: Arc<str>,
pub level: Level,
pub target: Arc<str>,
pub message: Arc<str>,
}
#[derive(Default)]
struct LogForwarder {
sender: Mutex<Option<UnboundedSender<LogEntry>>>,
}
impl LogForwarder {
fn has_sender(&self) -> bool {
match self.sender.lock() {
Ok(guard) => guard.is_some(),
Err(_) => false,
}
}
fn set_sender(&self, sender: UnboundedSender<LogEntry>) {
match self.sender.lock() {
Ok(mut guard) => {
*guard = Some(sender);
}
Err(err) => {
tracing::warn!("failed to set TUI log sender; lock poisoned: {err}");
}
}
}
fn clear_sender(&self) {
match self.sender.lock() {
Ok(mut guard) => {
*guard = None;
}
Err(err) => {
tracing::warn!("failed to clear TUI log sender; lock poisoned: {err}");
}
}
}
fn send(&self, entry: LogEntry) {
match self.sender.lock() {
Ok(guard) => {
if let Some(sender) = guard.as_ref() {
let _ = sender.send(entry);
}
}
Err(err) => {
tracing::warn!("failed to forward TUI log entry; lock poisoned: {err}");
}
}
}
}
static LOG_FORWARDER: Lazy<Arc<LogForwarder>> = Lazy::new(|| Arc::new(LogForwarder::default()));
static TUI_LOG_CAPTURE_ENABLED: AtomicBool = AtomicBool::new(cfg!(debug_assertions));
static LOG_THEME_NAME: Lazy<RwLock<Option<String>>> = Lazy::new(|| RwLock::new(None));
static LOG_THEME_CACHE: Lazy<RwLock<Option<(String, Theme)>>> = Lazy::new(|| RwLock::new(None));
#[derive(Default)]
struct FieldVisitor {
message: Option<String>,
extras: Vec<(String, String)>,
}
impl Visit for FieldVisitor {
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn fmt::Debug) {
let rendered = format!("{:?}", value);
if field.name() == "message" {
self.message = Some(rendered);
} else {
self.extras.push((field.name().to_string(), rendered));
}
}
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
if field.name() == "message" {
self.message = Some(value.to_string());
} else {
self.extras
.push((field.name().to_string(), value.to_string()));
}
}
}
pub struct TuiLogLayer {
forwarder: Arc<LogForwarder>,
}
impl TuiLogLayer {
pub fn new() -> Self {
Self {
forwarder: LOG_FORWARDER.clone(),
}
}
}
impl Default for TuiLogLayer {
fn default() -> Self {
Self::new()
}
}
impl TuiLogLayer {
pub fn set_sender(sender: UnboundedSender<LogEntry>) {
LOG_FORWARDER.set_sender(sender);
}
pub fn clear_sender() {
LOG_FORWARDER.clear_sender();
}
}
pub fn set_tui_log_capture_enabled(enabled: bool) {
TUI_LOG_CAPTURE_ENABLED.store(enabled, Ordering::SeqCst);
}
pub fn is_tui_log_capture_enabled() -> bool {
TUI_LOG_CAPTURE_ENABLED.load(Ordering::SeqCst)
}
impl<S> Layer<S> for TuiLogLayer
where
S: Subscriber + for<'span> LookupSpan<'span>,
{
fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
if !is_tui_log_capture_enabled() {
return;
}
if !self.forwarder.has_sender() {
return;
}
let level = *event.metadata().level();
if level == Level::TRACE {
return;
}
if level == Level::DEBUG && !crate::ui::tui::panic_hook::is_debug_mode() {
return;
}
if level == Level::ERROR && !crate::ui::tui::panic_hook::show_diagnostics() {
return;
}
let mut visitor = FieldVisitor::default();
event.record(&mut visitor);
let message = visitor
.message
.unwrap_or_else(|| "(no message)".to_string());
let extras = if visitor.extras.is_empty() {
String::new()
} else {
let rendered = visitor
.extras
.into_iter()
.map(|(key, value)| format!("{key}={value}"))
.collect::<Vec<_>>()
.join(" ");
format!(" {rendered}")
};
let combined_message = format!("{}{}", message, extras);
if should_filter_log_message(&combined_message) {
return;
}
let timestamp = format_rfc3339_seconds(SystemTime::now()).to_string();
let line = format!(
"{} {:<5} {} {}{}",
timestamp,
event.metadata().level(),
event.metadata().target(),
message,
extras
);
let log_target = Arc::from(event.metadata().target().to_string().into_boxed_str());
let message_with_extras = Arc::from(format!("{message}{extras}").into_boxed_str());
self.forwarder.send(LogEntry {
formatted: Arc::from(line.into_boxed_str()),
timestamp: Arc::from(timestamp.into_boxed_str()),
level: *event.metadata().level(),
target: log_target,
message: message_with_extras,
});
}
}
fn should_filter_log_message(message: &str) -> bool {
let lower_message = message.to_lowercase();
let malloc_filters = [
"mallocstacklogging",
"malscollogging",
"no such file or directory",
"can't turn off malloc stack logging",
"could not tag msl-related memory",
"those pages will be included in process footprint",
"process is not in a debuggable environment",
"unsetting mallocstackloggingdirectory environment variable",
];
malloc_filters
.iter()
.any(|&filter| lower_message.contains(&filter.to_lowercase()))
}
pub fn make_tui_log_layer() -> TuiLogLayer {
TuiLogLayer::new()
}
pub fn register_tui_log_sender(sender: UnboundedSender<LogEntry>) {
TuiLogLayer::set_sender(sender);
}
pub fn clear_tui_log_sender() {
TuiLogLayer::clear_sender();
}
pub fn set_log_theme_name(theme: Option<String>) {
let Ok(mut slot) = LOG_THEME_NAME.write() else {
tracing::warn!("failed to set TUI log theme name; theme lock poisoned");
return;
};
*slot = theme
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty());
drop(slot);
if let Ok(mut cache) = LOG_THEME_CACHE.write() {
*cache = None;
} else {
tracing::warn!("failed to clear TUI log theme cache; cache lock poisoned");
}
}
fn theme_for_current_config() -> Theme {
let theme_name = {
let Ok(slot) = LOG_THEME_NAME.read() else {
tracing::warn!("failed to read TUI log theme name; falling back to default theme");
return load_theme(&default_theme_name(), true);
};
slot.clone()
};
let resolved_name = theme_name.clone().unwrap_or_else(default_theme_name);
{
if let Ok(cache) = LOG_THEME_CACHE.read()
&& let Some((cached_name, cached)) = &*cache
&& *cached_name == resolved_name
{
return cached.clone();
}
}
let theme = load_theme(&resolved_name, true);
if let Ok(mut cache) = LOG_THEME_CACHE.write() {
*cache = Some((resolved_name, theme.clone()));
}
theme
}
fn syntect_to_ratatui_style(style: syntect::highlighting::Style) -> Style {
let fg = style.foreground;
Style::default().fg(Color::Rgb(fg.r, fg.g, fg.b))
}
fn highlight_lines_to_text<'a>(
lines: impl Iterator<Item = &'a str>,
syntax: &SyntaxReference,
) -> Text<'static> {
let theme = theme_for_current_config();
let ss = syntax_set();
let mut highlighter = HighlightLines::new(syntax, &theme);
let mut result_lines = Vec::new();
for line in lines {
match highlighter.highlight_line(line, ss) {
Ok(ranges) => {
let spans: Vec<Span<'static>> = ranges
.into_iter()
.map(|(style, text)| {
Span::styled(text.to_owned(), syntect_to_ratatui_style(style))
})
.collect();
result_lines.push(Line::from(spans));
}
Err(_) => {
result_lines.push(Line::raw(line.to_owned()));
}
}
}
Text::from(result_lines)
}
fn log_level_style(level: &Level) -> Style {
match *level {
Level::ERROR => Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
Level::WARN => Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
Level::INFO => Style::default().fg(Color::Green),
Level::DEBUG => Style::default().fg(Color::Blue),
Level::TRACE => Style::default().fg(Color::DarkGray),
}
}
fn log_prefix(entry: &LogEntry) -> Vec<Span<'static>> {
let timestamp_style = Style::default().fg(Color::DarkGray);
vec![
Span::styled(format!("[{}]", entry.timestamp), timestamp_style),
Span::raw(" "),
Span::styled(format!("{:<5}", entry.level), log_level_style(&entry.level)),
Span::raw(" "),
Span::styled(entry.target.to_string(), Style::default().fg(Color::Gray)),
Span::raw(" "),
]
}
fn prepend_metadata(text: &mut Text<'static>, entry: &LogEntry) {
let mut prefix = log_prefix(entry);
if let Some(first) = text.lines.first_mut() {
let mut merged = Vec::with_capacity(prefix.len() + first.spans.len());
merged.append(&mut prefix);
merged.append(&mut first.spans);
first.spans = merged;
} else {
text.lines.push(Line::from(prefix));
}
}
fn select_syntax(message: &str) -> &'static SyntaxReference {
let trimmed = message.trim_start();
if !trimmed.is_empty() {
if (trimmed.starts_with('{') || trimmed.starts_with('['))
&& let Some(json) =
find_syntax_by_name("JSON").or_else(|| find_syntax_by_extension("json"))
{
return json;
}
if (trimmed.contains('$') || trimmed.contains(';'))
&& let Some(shell) =
find_syntax_by_name("Bash").or_else(|| find_syntax_by_extension("sh"))
{
return shell;
}
}
find_syntax_by_name("Rust")
.or_else(|| find_syntax_by_extension("rs"))
.unwrap_or_else(find_syntax_plain_text)
}
pub fn highlight_log_entry(entry: &LogEntry) -> Text<'static> {
let ss = syntax_set();
if ss.syntaxes().is_empty() {
let mut text = Text::raw(entry.formatted.as_ref().to_string());
prepend_metadata(&mut text, entry);
return text;
}
let syntax = select_syntax(entry.message.as_ref());
let mut highlighted = highlight_lines_to_text(entry.message.lines(), syntax);
prepend_metadata(&mut highlighted, entry);
highlighted
}