multiline_logger/
lib.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// TODO: it's very possible to make this stable-able:
3// - ptr_metadata: remove if log#666 gets resolved
4// - panic_payload_as_str: just inline the implementation
5#![feature(ptr_metadata, panic_payload_as_str)]
6//! [![Repository](https://img.shields.io/badge/repository-GitHub-brightgreen.svg)](https://github.com/1e1001/rsutil/tree/main/multiline-logger)
7//! [![Crates.io](https://img.shields.io/crates/v/multiline-logger)](https://crates.io/crates/multiline-logger)
8//! [![docs.rs](https://img.shields.io/docsrs/multiline-logger)](https://docs.rs/multiline-logger)
9//! [![MIT OR Apache-2.0](https://img.shields.io/crates/l/multiline-logger)](https://github.com/1e1001/rsutil/blob/main/multiline-logger/README.md#License)
10//!
11//! Fancy lightweight debug output:
12//! - Not excessively dynamic but still configurable
13//! - Logs messages and crashes
14//! - Looks very nice (in my opinion)
15//!
16//! | Platform | Console output | File output | Backtraces |
17//! |-:|-|:-:|-|-|
18//! | Native | `stderr` (colored!) | ✓ | `backtrace` feature |
19//! | Web | web `console` (colored!) | ✗ | `backtrace` feature |
20//!
21//! Get started by creating a [`Settings`] and calling [`init`].
22//!
23//! [`init`]: Settings::init
24
25use std::mem::replace;
26use std::num::NonZeroU32;
27use std::panic::Location;
28use std::path::Path;
29use std::sync::Mutex;
30use std::thread::{self, Thread, ThreadId};
31use std::{fmt, panic, ptr};
32
33/// For convenience :)
34pub use log;
35use log::{Level, LevelFilter, Log, set_logger, set_max_level};
36use sys_abstract::{MaybeBacktrace, SystemImpl};
37use time::{Date, OffsetDateTime};
38
39mod sys_abstract;
40
41#[cfg(target_arch = "wasm32")]
42mod sys_web;
43#[cfg(target_arch = "wasm32")]
44use sys_web::System;
45
46#[cfg(not(target_arch = "wasm32"))]
47mod sys_native;
48#[cfg(not(target_arch = "wasm32"))]
49use sys_native::System;
50
51/// Settings for the logger
52pub struct Settings {
53	/// A human-readable name for the application
54	pub title: &'static str,
55	/// List of module-prefix filters to match against,
56	/// earlier filters get priority
57	pub filters: &'static [(&'static str, LevelFilter)],
58	/// Optional file path to output to (desktop only)
59	pub file_out: Option<&'static Path>,
60	/// Set to `true` to output to an appropriate console
61	pub console_out: bool,
62	/// Set to `true` to enable the panic hook
63	pub panic_hook: bool,
64}
65
66impl Settings {
67	/// Initializes the logger
68	///
69	/// # Panics
70	/// will panic if initialization fails in any way
71	pub fn init(self) {
72		let Self {
73			title,
74			filters,
75			file_out,
76			console_out,
77			panic_hook,
78		} = self;
79		let max_level = filters
80			.iter()
81			.map(|&(_, level)| level)
82			.max()
83			.unwrap_or(LevelFilter::Off);
84		if panic_hook {
85			// set the hook before installing the logger,
86			// to show panic messages if logger initialization breaks
87			panic::set_hook(Box::new(panic_handler));
88		}
89		let date = now().date();
90		let logger = Logger {
91			title,
92			filters,
93			file_out: file_out.map(System::file_new),
94			console_out: console_out.then(System::console_new),
95			prev_day: Mutex::new(date.to_julian_day()),
96		};
97		let message = Header { title, date };
98		if let Some(out) = &logger.file_out {
99			System::file_p_header(out, &message);
100		}
101		if let Some(out) = &logger.console_out {
102			System::console_p_header(out, &message);
103		}
104		set_logger(upcast_log(Box::leak(Box::new(logger)))).expect("Failed to apply logger");
105		set_max_level(max_level);
106	}
107}
108
109// TODO: remove this once log#666 gets resolved
110fn as_dyn_ref(logger: *const Logger) -> *const dyn Log {
111	// split into one function to always attach the same metadata
112	logger as *const dyn Log
113}
114fn upcast_log(logger: &'static Logger) -> &'static dyn Log {
115	// SAFETY: as_dyn_ref returns a reference to the same object as passed in
116	unsafe { &*as_dyn_ref(logger) }
117}
118fn downcast_log(log: &'static dyn Log) -> Option<&'static Logger> {
119	// horribly cursed implementation to fetch a reference to the installed logger
120	let (logger_ptr, logger_meta) = (&raw const *log).to_raw_parts();
121	let (_, fake_logger_meta) = as_dyn_ref(ptr::null::<Logger>()).to_raw_parts();
122	(logger_meta == fake_logger_meta).then(|| {
123		// SAFETY: v-tables match so it's probably ours!
124		unsafe { &*logger_ptr.cast::<Logger>() }
125	})
126}
127
128// logger context
129struct Logger {
130	title: &'static str,
131	filters: &'static [(&'static str, LevelFilter)],
132	file_out: Option<<System as SystemImpl>::File>,
133	console_out: Option<<System as SystemImpl>::Console>,
134	prev_day: Mutex<i32>,
135}
136
137impl Log for Logger {
138	fn enabled(&self, meta: &log::Metadata) -> bool {
139		for (name, level) in self.filters {
140			if meta.target().starts_with(name) {
141				return *level >= meta.level();
142			}
143		}
144		false
145	}
146	fn log(&self, record: &log::Record) {
147		if self.enabled(record.metadata()) {
148			let now = now();
149			let date = now.date();
150			let day = date.to_julian_day();
151			let date = match self.prev_day.lock() {
152				Ok(mut lock) => (replace(&mut *lock, day) != day).then_some(date),
153				Err(_) => None,
154			};
155			let thread = thread::current();
156			let message = Record {
157				date,
158				module: record.module_path().unwrap_or("?"),
159				line: NonZeroU32::new(record.line().unwrap_or(0)),
160				thread: ThreadName::new(&thread),
161				args: *record.args(),
162				hmsms: now.time().as_hms_milli(),
163				level: record.level(),
164			};
165			if let Some(out) = &self.file_out {
166				System::file_p_record(out, &message);
167			}
168			if let Some(out) = &self.console_out {
169				System::console_p_record(out, &message);
170			}
171		}
172	}
173	fn flush(&self) {
174		self.file_out.as_ref().map(System::file_flush);
175		self.console_out.as_ref().map(System::console_flush);
176	}
177}
178
179fn now() -> OffsetDateTime {
180	OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc())
181}
182
183#[derive(Debug)]
184enum ThreadName<'data> {
185	Name(&'data str),
186	Id(ThreadId),
187}
188impl<'data> ThreadName<'data> {
189	fn new(thread: &'data Thread) -> Self {
190		if let Some(name) = thread.name() {
191			Self::Name(name)
192		} else {
193			Self::Id(thread.id())
194		}
195	}
196}
197impl fmt::Display for ThreadName<'_> {
198	fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
199		match self {
200			ThreadName::Name(name) => write!(f, "Thread {name:?}"),
201			ThreadName::Id(id) => write!(f, "{id:?}"),
202		}
203	}
204}
205
206/// ```text
207/// {BY}== title - date ==
208/// ```
209struct Header {
210	title: &'static str,
211	date: Date,
212}
213
214/// ```text
215/// {BY}= date =
216/// {BB}h:m:s.ms {BG}module:line{BM} thread
217/// {L}level {0}message
218/// {L}    | {0}message
219/// ```
220struct Record<'data> {
221	date: Option<Date>,
222	module: &'data str,
223	line: Option<NonZeroU32>,
224	thread: ThreadName<'data>,
225	args: fmt::Arguments<'data>,
226	hmsms: (u8, u8, u8, u16),
227	level: Level,
228}
229
230/// ```text
231/// {BR}== title - {BM}thread{BR} Panic ==
232/// {0}message
233/// {BG}→ location
234/// {0}backtrace```
235#[derive(Debug)]
236struct Panic<'data> {
237	thread: ThreadName<'data>,
238	/// Panic text
239	message: Option<&'data str>,
240	/// Panic location
241	location: Option<Location<'data>>,
242	/// Application title
243	title: &'data str,
244	/// Log file path, if you want to open it
245	path: Option<&'data Path>,
246	/// Backtrace, probably hard to access yourself
247	trace: MaybeBacktrace<System>,
248}
249
250impl Panic<'_> {
251	fn message_str(&self) -> &str { self.message.unwrap_or("[non-string message]") }
252	fn location_display(&self) -> &dyn fmt::Display {
253		self.location.as_ref().map_or(&"[citation needed]", |v| v)
254	}
255}
256
257fn panic_handler(info: &panic::PanicHookInfo) {
258	let logger = downcast_log(log::logger());
259	let thread = thread::current();
260	let mut message = Panic {
261		thread: ThreadName::new(&thread),
262		message: info.payload_as_str(),
263		location: info.location().copied(),
264		title: "[pre-init?]",
265		path: None,
266		trace: System::backtrace_new(),
267	};
268	if let Some(logger) = logger {
269		message.title = logger.title;
270		message.path = System::file_path(logger.file_out.as_ref());
271		if let Some(out) = &logger.file_out {
272			System::file_p_panic(out, &message);
273		}
274		if let Some(out) = &logger.console_out {
275			System::console_p_panic(out, &message);
276		}
277		// TODO: do fancy dialog stuff here
278		// and do it in a way that lets custom dialog handler run
279		// e.g. show a window popup, or an in-game overlay frame (like N64)
280	} else {
281		System::fallback_p_panic(&message);
282	}
283}