Pumpkin/pumpkin/src/logging.rs
2026-05-02 18:24:52 +02:00

570 lines
18 KiB
Rust

#![allow(clippy::print_stderr)]
#![allow(clippy::print_stdout)]
use flate2::write::GzEncoder;
use rustyline::completion::Completer;
use rustyline::highlight::Highlighter;
use rustyline::hint::Hinter;
use rustyline::history::FileHistory;
use rustyline::validate::Validator;
use rustyline::{Editor, Helper};
use std::borrow::Cow;
use std::collections::VecDeque;
use std::fs::File;
use std::io::{self, BufWriter, Write};
use std::path::PathBuf;
use std::sync::Arc;
use time::{Duration, OffsetDateTime};
use tracing::Subscriber;
use tracing_subscriber::Layer;
use tracing_subscriber::filter::LevelFilter;
use crate::command::CommandSender;
use crate::command::string_reader::StringReader;
use crate::command::tree::NodeType;
use crate::server::Server;
#[macro_export]
macro_rules! log_at_level {
($level:expr, $($arg:tt)*) => {
match $level {
tracing::Level::TRACE => tracing::trace!($($arg)*),
tracing::Level::DEBUG => tracing::debug!($($arg)*),
tracing::Level::INFO => tracing::info!($($arg)*),
tracing::Level::WARN => tracing::warn!($($arg)*),
tracing::Level::ERROR => tracing::error!($($arg)*),
}
};
}
#[macro_export]
macro_rules! plugin_log {
($level:expr, $plugin_name:expr, $($arg:tt)*) => {{
let plugin_name = $plugin_name;
match $level {
tracing::Level::TRACE => {
tracing::trace!(
target: "pumpkin_plugin",
plugin = plugin_name,
$($arg)*
)
},
tracing::Level::DEBUG => {
tracing::debug!(
target: "pumpkin_plugin",
plugin = plugin_name,
$($arg)*
)
},
tracing::Level::INFO => {
tracing::info!(
target: "pumpkin_plugin",
plugin = plugin_name,
$($arg)*
)
},
tracing::Level::WARN => {
tracing::warn!(
target: "pumpkin_plugin",
plugin = plugin_name,
$($arg)*
)
},
tracing::Level::ERROR => {
tracing::error!(
target: "pumpkin_plugin",
plugin = plugin_name,
$($arg)*
)
},
}
}};
}
const LOG_DIR: &str = "logs";
const MAX_ATTEMPTS: u32 = 1000;
/// A wrapper for our logger to hold the terminal input while no input is expected in order to
/// properly flush logs to the output while they happen instead of batched
pub struct ReadlineLogWrapper {
readline: std::sync::Mutex<Option<Editor<PumpkinCommandCompleter, FileHistory>>>,
}
struct GzipRollingLoggerData {
pub current_day_of_month: u8,
pub last_rotate_time: time::OffsetDateTime,
pub file: BufWriter<File>,
latest_filename: String,
}
pub struct GzipRollingLogger {
log_level: LevelFilter,
data: std::sync::Mutex<GzipRollingLoggerData>,
}
impl GzipRollingLogger {
pub fn new(
log_level: LevelFilter,
filename: String,
) -> Result<Self, Box<dyn std::error::Error>> {
let now = time::OffsetDateTime::now_utc();
std::fs::create_dir_all(LOG_DIR)?;
let latest_path = PathBuf::from(LOG_DIR).join(&filename);
// If latest.log exists, we will gzip it
if latest_path.exists() {
eprintln!(
"Found existing log file at '{}', gzipping it now...",
latest_path.display()
);
let new_gz_path = Self::new_filename(true)?;
let mut file = File::open(&latest_path)?;
let mut encoder = GzEncoder::new(
BufWriter::new(File::create(&new_gz_path)?),
flate2::Compression::best(),
);
io::copy(&mut file, &mut encoder)?;
encoder.finish()?;
std::fs::remove_file(&latest_path)?;
}
let file = BufWriter::new(File::create(&latest_path)?);
Ok(Self {
log_level,
data: std::sync::Mutex::new(GzipRollingLoggerData {
current_day_of_month: now.day(),
last_rotate_time: now,
latest_filename: filename,
file,
}),
})
}
pub fn new_filename(yesterday: bool) -> Result<PathBuf, Box<dyn std::error::Error>> {
let mut now = OffsetDateTime::now_utc();
if yesterday {
now -= Duration::days(1);
}
let date_format = format!("{}-{:02}-{:02}", now.year(), now.month() as u8, now.day());
let log_path = PathBuf::from(LOG_DIR);
let mut oldest_log = None;
for id in 1..=MAX_ATTEMPTS {
let filename = log_path.join(format!("{date_format}-{id}.log.gz"));
if !filename.exists() {
return Ok(filename);
}
let Ok(modified_time) = filename.metadata().and_then(|m| m.modified()) else {
continue;
};
if let Some((_, old_time)) = oldest_log {
if modified_time < old_time {
oldest_log = Some((filename, modified_time));
}
continue;
}
oldest_log = Some((filename, modified_time));
}
if let Some((path, _)) = oldest_log {
eprintln!(
"Max log ids ({MAX_ATTEMPTS}) used for {date_format}; overwriting oldest log file: {}",
path.display()
);
return Ok(path);
}
Err(format!(
"Failed to find a unique log filename for date {date_format} after {MAX_ATTEMPTS} attempts.",
)
.into())
}
fn rotate_log(&self) -> Result<(), Box<dyn std::error::Error>> {
let now = time::OffsetDateTime::now_utc();
let mut data = self.data.lock().unwrap();
let new_gz_path = Self::new_filename(true)?;
let latest_path = PathBuf::from(LOG_DIR).join(&data.latest_filename);
// Flush and drop the current file
data.file.flush()?;
drop(std::mem::replace(
&mut data.file,
BufWriter::new(File::create("/dev/null")?),
));
let mut file = File::open(&latest_path)?;
let mut encoder = GzEncoder::new(
BufWriter::new(File::create(&new_gz_path)?),
flate2::Compression::best(),
);
io::copy(&mut file, &mut encoder)?;
encoder.finish()?;
data.current_day_of_month = now.day();
data.last_rotate_time = now;
data.file = BufWriter::new(File::create(&latest_path)?);
Ok(())
}
}
fn remove_ansi_color_code(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut it = s.chars();
while let Some(c) = it.next() {
if c == '\x1b' {
for c_seq in it.by_ref() {
if c_seq.is_ascii_alphabetic() {
break;
}
}
} else {
result.push(c);
}
}
result
}
impl<S> Layer<S> for GzipRollingLogger
where
S: Subscriber,
{
fn on_event(
&self,
event: &tracing::Event<'_>,
_ctx: tracing_subscriber::layer::Context<'_, S>,
) {
let metadata = event.metadata();
let level = metadata.level();
// Check if we should log this event based on level
let should_log = match *level {
tracing::Level::ERROR => self.log_level >= LevelFilter::ERROR,
tracing::Level::WARN => self.log_level >= LevelFilter::WARN,
tracing::Level::INFO => self.log_level >= LevelFilter::INFO,
tracing::Level::DEBUG => self.log_level >= LevelFilter::DEBUG,
tracing::Level::TRACE => self.log_level >= LevelFilter::TRACE,
};
if !should_log {
return;
}
let now = time::OffsetDateTime::now_utc();
if let Ok(mut data) = self.data.lock() {
// Format the event
let mut visitor = StringVisitor::default();
event.record(&mut visitor);
let message = visitor.0;
let clean_message = remove_ansi_color_code(&message);
// Write to file
let _ = writeln!(data.file, "[{level}] {clean_message}");
let _ = data.file.flush();
// Check if we need to rotate
if data.current_day_of_month != now.day() {
drop(data);
if let Err(e) = self.rotate_log() {
eprintln!("Failed to rotate log: {e}");
}
}
}
}
}
#[derive(Default)]
struct StringVisitor(String);
impl tracing::field::Visit for StringVisitor {
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
if field.name() == "message" {
self.0 = format!("{value:?}");
// Remove quotes if present
if self.0.starts_with('"') && self.0.ends_with('"') {
self.0 = self.0[1..self.0.len() - 1].to_string();
}
}
}
}
impl ReadlineLogWrapper {
#[must_use]
pub const fn new(rl: Option<Editor<PumpkinCommandCompleter, FileHistory>>) -> Self {
Self {
readline: std::sync::Mutex::new(rl),
}
}
pub fn take_readline(&self) -> Option<Editor<PumpkinCommandCompleter, FileHistory>> {
self.readline
.lock()
.map_or_else(|_| None, |mut result| result.take())
}
// This isn't really dead code. It is just only used by the lib and not the bin for this
// crate, and as such creates a compiler warning.
#[allow(dead_code)]
pub fn return_readline(&self, rl: Editor<PumpkinCommandCompleter, FileHistory>) {
if let Ok(mut result) = self.readline.lock() {
let _ = result.insert(rl);
}
}
}
#[derive(Clone, Default)]
pub struct PumpkinCommandCompleter {
pub server: Arc<std::sync::RwLock<Option<Arc<Server>>>>,
pub rt: Arc<std::sync::OnceLock<tokio::runtime::Handle>>,
}
impl PumpkinCommandCompleter {
#[must_use]
pub fn new() -> Self {
Self {
server: Arc::new(std::sync::RwLock::new(None)),
rt: Arc::new(std::sync::OnceLock::new()),
}
}
}
impl Helper for PumpkinCommandCompleter {}
impl Highlighter for PumpkinCommandCompleter {
fn highlight<'l>(&self, line: &'l str, _pos: usize) -> Cow<'l, str> {
line.find(' ').map_or_else(
|| Cow::Owned(format!("\x1b[1;36m{line}\x1b[0m")),
|first_space| {
let (cmd, args) = line.split_at(first_space);
Cow::Owned(format!("\x1b[1;36m{cmd}\x1b[0m{args}"))
},
)
}
fn highlight_hint<'h>(&self, hint: &'h str) -> Cow<'h, str> {
Cow::Owned(format!("\x1b[90m{hint}\x1b[0m"))
}
}
impl Hinter for PumpkinCommandCompleter {
type Hint = String;
fn hint(&self, line: &str, pos: usize, ctx: &rustyline::Context<'_>) -> Option<Self::Hint> {
if line.is_empty() || pos < line.len() {
return None;
}
if let Ok((_, candidates)) = self.complete(line, pos, ctx)
&& let Some(first) = candidates.first()
{
let last_word = line.split_whitespace().last().unwrap_or("");
if first.starts_with('<') {
return line.ends_with(' ').then(|| first.clone());
}
if let Some(stripped) = first.strip_prefix(last_word) {
return Some(stripped.to_string());
}
}
None
}
}
impl Validator for PumpkinCommandCompleter {}
impl Completer for PumpkinCommandCompleter {
type Candidate = String;
#[expect(clippy::too_many_lines)]
fn complete(
&self,
line: &str,
pos: usize,
_ctx: &rustyline::Context<'_>,
) -> rustyline::Result<(usize, Vec<Self::Candidate>)> {
let cmd_to_cursor = &line[..pos];
let has_slash = cmd_to_cursor.starts_with('/');
let cmd = if has_slash {
&cmd_to_cursor[1..]
} else {
cmd_to_cursor
};
let Some(handle) = self.rt.get() else {
return Ok((0, Vec::new()));
};
let Ok(server_guard) = self.server.try_read() else {
return Ok((0, Vec::new()));
};
let Some(server) = server_guard.as_ref() else {
return Ok((0, Vec::new()));
};
let parts: Vec<&str> = cmd.split_whitespace().collect();
let ends_with_space = cmd.ends_with(' ');
handle.block_on(async {
let dispatcher = server.command_dispatcher.read().await;
let source = CommandSender::Console.into_source(server).await;
// Temporary setups to unify both dispatchers for now:
{
if cmd.trim().is_empty() {
// Give all commands as suggestions.
let suggestions: Vec<String> = dispatcher
.get_all_commands()
.keys()
.map(ToString::to_string)
.collect();
return Ok((pos, suggestions));
}
}
{
let mut reader = StringReader::new(cmd);
if reader.peek() == Some('/') {
reader.skip();
}
let parsed = dispatcher.parse(&mut reader, &source).await;
let suggestions = dispatcher.get_completion_suggestions(parsed, pos).await;
if !suggestions.is_empty() {
let start = suggestions.range.start;
let suggestions = suggestions
.suggestions
.into_iter()
.map(|s| s.text.cached_text().clone())
.collect();
return Ok((start, suggestions));
}
}
let dispatcher = &dispatcher.fallback_dispatcher;
let src = CommandSender::Console;
if parts.is_empty() || (parts.len() == 1 && !ends_with_space) {
let typing = parts.first().unwrap_or(&"");
let candidates = dispatcher
.commands
.keys()
.filter(|k| k.starts_with(typing))
.cloned()
.collect();
return Ok((usize::from(has_slash), candidates));
}
let Some(tree) = dispatcher.get_tree(parts[0]).ok() else {
return Ok((0, Vec::new()));
};
let mut current_indices = tree.children.clone();
let mut word_index = 1;
let walk_limit = if ends_with_space {
parts.len()
} else {
parts.len() - 1
};
while word_index < walk_limit {
let token = parts[word_index];
let mut next_indices = Vec::new();
let mut worklist: VecDeque<usize> = current_indices.iter().copied().collect();
while let Some(idx) = worklist.pop_front() {
let node = &tree.nodes[idx];
match &node.node_type {
NodeType::Require { predicate } => {
if predicate(&src) {
worklist.extend(node.children.iter().copied());
}
}
NodeType::Literal { string } => {
if string.eq_ignore_ascii_case(token) {
next_indices.extend(node.children.iter().copied());
}
}
NodeType::Argument { .. } => {
next_indices.extend(node.children.iter().copied());
}
NodeType::ExecuteLeaf { .. } => {}
}
}
if next_indices.is_empty() {
return Ok((0, Vec::new()));
}
current_indices = next_indices;
word_index += 1;
}
let typing = if ends_with_space {
""
} else {
parts.last().unwrap_or(&"")
};
let mut candidates = Vec::new();
let mut suggestion_worklist: VecDeque<usize> = current_indices.into_iter().collect();
while let Some(idx) = suggestion_worklist.pop_front() {
let node = &tree.nodes[idx];
match &node.node_type {
NodeType::Require { predicate } => {
if predicate(&src) {
suggestion_worklist.extend(node.children.iter().copied());
}
}
NodeType::Literal { string } => {
if string.starts_with(typing) {
candidates.push(string.clone());
}
}
NodeType::Argument { name, consumer } => {
let suggest_future = consumer.suggest(&src, server, typing);
if let Ok(Some(suggestions)) = suggest_future.await {
for s in suggestions {
let s = s.suggestion;
if s.starts_with(typing) {
candidates.push(s);
}
}
} else {
let placeholder = format!("<{name}>");
if placeholder.starts_with(typing) || typing.is_empty() {
candidates.push(placeholder);
}
}
}
NodeType::ExecuteLeaf { executor: _ } => {}
}
}
let last_space = cmd.rfind(' ').map_or(0, |i| i + 1);
Ok((last_space + usize::from(has_slash), candidates))
})
}
}