rm_lisa/display/renderers/
mod.rs

1//! Renderers are responsible for actually rendering a series of log lines.
2//!
3//! They take in a structure of data that contains an arbitrary sequence of
4//! bytes that represent at least one line of a log (or potentially more if the
5//! line is very long and we want to split it up!), some metadata attached to
6//! that log line, and gives us the final human readable representation.
7//!
8//! There's nothing special about the renderers implemented in our crate, and
9//! if you want to print a custom format, include or exclude certain things you
10//! can do so by implementing the [`ConsoleRenderer`] trait.
11//!
12//! ## Note about ANSI Enabling Code
13//!
14//! Code related to ANSI-enablement for Windows has been based off of crossterm which
15//! is licensed under MIT at the point of forking:
16//! <https://github.com/crossterm-rs/crossterm/blob/d5b0e0700752b37cda613c949b7bbe78f956f166/LICENSE>
17//!
18//! The code has been modified to remove support from the winapi crate, and
19//! instead depend on [`windows`] also removed the dependency on once-cell, and
20//! instead use the stabilized lazy features of std.
21
22mod color;
23mod json;
24mod text;
25
26pub use crate::display::renderers::{
27	color::ColorConsoleRenderer, json::JSONConsoleRenderer, text::TextConsoleRenderer,
28};
29
30use crate::{
31	display::tracing::SuperConsoleLogMessage,
32	errors::LisaError,
33	input::{InputProvider, TerminalInputEvent},
34	tasks::{GloballyUniqueTaskId, LisaTaskStatus, TaskEvent},
35};
36use chrono::{DateTime, Utc};
37use fnv::FnvHashMap;
38use regex::Regex;
39use std::{
40	collections::VecDeque,
41	fs::File,
42	io::{
43		BufWriter, Cursor, Empty, IsTerminal, LineWriter, PipeWriter, Sink, Stderr, StderrLock,
44		Stdout, StdoutLock, Write,
45	},
46	net::TcpStream,
47	sync::{Arc, OnceLock},
48};
49
50#[cfg(unix)]
51use std::os::unix::net::UnixStream;
52#[cfg(target_os = "windows")]
53use std::{
54	env::var as env_var,
55	sync::{
56		Once,
57		atomic::{AtomicBool, Ordering as AtomicOrdering},
58	},
59};
60#[cfg(target_os = "windows")]
61use windows::Win32::System::Console::{
62	CONSOLE_MODE, ENABLE_VIRTUAL_TERMINAL_PROCESSING, GetConsoleMode, GetStdHandle,
63	STD_ERROR_HANDLE, STD_OUTPUT_HANDLE, SetConsoleMode,
64};
65
66#[cfg(target_os = "windows")]
67/// If we've tried to enable ansi support...
68static STDOUT_ANSI_INITIALIZER: Once = Once::new();
69#[cfg(target_os = "windows")]
70/// Whether or not we do support ANSI Escape Codes.
71static STDOUT_SUPPORTS_ANSI_ESCAPE_CODES: AtomicBool = AtomicBool::new(false);
72
73#[cfg(target_os = "windows")]
74/// If we've tried to enable ansi support...
75static STDERR_ANSI_INITIALIZER: Once = Once::new();
76#[cfg(target_os = "windows")]
77/// Whether or not we do support ANSI Escape Codes.
78static STDERR_SUPPORTS_ANSI_ESCAPE_CODES: AtomicBool = AtomicBool::new(false);
79/// Copy of ansi escape code regex that we use for ensuring colored
80/// output doesn't get printed!
81static ANSI_ESCAPE_CODE_REGEX: OnceLock<Regex> = OnceLock::new();
82
83/// Get a regex that can match all ANSI Escape Codes.
84pub fn get_ansi_escape_code_regex() -> Regex {
85	ANSI_ESCAPE_CODE_REGEX
86		.get_or_init(|| {
87			let Ok(regex) = Regex::new(
88				r"(?:\x1B[@-Z\\-_]|[\x80-\x9A\x9C-\x9F]|(?:\x1B\[|\x9B)[0-?]*[ -/]*[@-~])",
89			) else {
90				unimplemented!("Regex is validated pre-compile time...");
91			};
92
93			regex
94		})
95		.clone()
96}
97
98/// An arbitrary structure that is capable of rendering log lines.
99pub trait ConsoleRenderer: Send + Sync {
100	/// Get a renderer if it is compatible, and should be used with a set of
101	/// stream features.
102	///
103	/// The first console renderer that returns true will be used.
104	///
105	/// ## Parameters
106	///
107	/// - `features`: The current features for this particular stream.
108	/// - `environment_prefix`: a prefix to apply to environment variables,
109	///   usually the app name. This makes it so you can have multiple CLI
110	///   tools that are each controlled with things like:
111	///   `${APP_NAME}_LOG_FORMAT`. Lets say your app name was "sprig", in this
112	///   case the environment prefix would be: "SPRIG_".
113	#[must_use]
114	fn should_use_renderer(
115		&self,
116		stream_features: &dyn ConsoleOutputFeatures,
117		environment_prefix: &str,
118	) -> bool;
119
120	/// Return if this renderer supports 'cursor movement', using ANSI escape
121	/// codes or similar to move the cursor.
122	///
123	/// This will enable left/right arrow movement on the associated input
124	/// provider.
125	#[must_use]
126	fn supports_ansi(&self) -> bool;
127
128	/// Get the default PS1 to use for this renderer, if none was provided.
129	#[must_use]
130	fn default_ps1(&self) -> String;
131
132	/// Update the 'PS1', or characters the get rendered before typing in a
133	/// command.
134	fn update_ps1(&self, new_ps1: String);
135
136	/// Clear any previously rendered input line if one was rendered.
137	#[must_use]
138	fn clear_input(&self, term_width: u16) -> String;
139	/// Clear any previous rendered tasks in our task list.
140	#[must_use]
141	fn clear_task_list(&self, task_list_size: usize) -> String;
142	/// A flag used to 'pause' rendering log events for awhile.
143	///
144	/// This will prevent [`ConsoleRenderer::render_message`] calls for your
145	/// renderer until this returns false. This should ideally only be used for
146	/// renderers that need to pause rendering _while_ user input is happening
147	/// because ASCII codes for erasing aren't supported.
148	#[must_use]
149	fn should_pause_log_events(&self, provider: &dyn InputProvider) -> bool;
150
151	/// Render a log message.
152	///
153	/// This message will be printed directly to it's source, whether that be
154	/// standard out, or standard error. This *can* span multiple lines.
155	///
156	/// ## Errors
157	///
158	/// If we can't format the log message into a string, or some other
159	/// processing error dependening on the renderer.
160	fn render_message(
161		&self,
162		app_name: &'static str,
163		log: SuperConsoleLogMessage,
164		term_width: u16,
165	) -> Result<String, LisaError>;
166
167	/// Render the console 'input' line for a particular provider.
168	///
169	/// It is the job of this method to query the provider, and determine how to
170	/// render the users input in the terminal.
171	///
172	/// ## Errors
173	///
174	/// If we can't format the input line into a string, or other processing
175	/// error occurs.
176	fn render_input(
177		&self,
178		app_name: &'static str,
179		provider: &dyn InputProvider,
180		term_width: u16,
181	) -> Result<String, LisaError>;
182
183	/// Re-render the task (you can assume it has been erased by this time it is called).
184	///
185	/// Passed in will be all new events that have happened (incase you want to render any task
186	/// updates), along with the current task states after all `new_task_events` have been
187	/// applied.
188	///
189	/// ## Errors
190	///
191	/// If there is any error related to rendering tasks, usually a formatting
192	/// error but depends on the specific renderer.
193	fn rerender_tasks(
194		&self,
195		new_task_events: &[TaskEvent],
196		current_task_states: &FnvHashMap<
197			GloballyUniqueTaskId,
198			(DateTime<Utc>, String, LisaTaskStatus),
199		>,
200		tasks_running_since: Option<DateTime<Utc>>,
201		term_height: u16,
202	) -> Result<String, LisaError>;
203
204	/// Do a 'quick render' of any sort of input, or handle an input on any kind.
205	///
206	/// This should be as immediate as possible to reduce any sort of latency
207	/// from the users who are typing.
208	///
209	/// ## Errors
210	///
211	/// If we can't format the input line into a string, or other processing
212	/// error occurs.
213	fn on_input(
214		&self,
215		event: TerminalInputEvent,
216		provider: &dyn InputProvider,
217	) -> Result<String, LisaError>;
218}
219
220/// Describe an arbitrary set of features that are available on an output.
221///
222/// Most outputs should automatically have this implemented, but if you're
223/// using a custom type you may need to implement it yourself.
224pub trait ConsoleOutputFeatures {
225	/// If this output is a TTY.
226	#[must_use]
227	fn is_atty(&self) -> bool;
228
229	/// Attempt to enable ANSI support, this will return whether or not enabling
230	/// was successful.
231	#[must_use]
232	fn enable_ansi(&self) -> bool;
233}
234
235impl ConsoleOutputFeatures for Stdout {
236	fn is_atty(&self) -> bool {
237		self.is_terminal()
238	}
239
240	/// Only windows terminals have to have ansi ENABLED.
241	#[cfg(not(target_os = "windows"))]
242	fn enable_ansi(&self) -> bool {
243		true
244	}
245
246	#[cfg(target_os = "windows")]
247	fn enable_ansi(&self) -> bool {
248		enable_ansi_stdout()
249	}
250}
251impl ConsoleOutputFeatures for StdoutLock<'_> {
252	fn is_atty(&self) -> bool {
253		self.is_terminal()
254	}
255
256	/// Only windows terminals have to have ansi ENABLED.
257	#[cfg(not(target_os = "windows"))]
258	fn enable_ansi(&self) -> bool {
259		true
260	}
261
262	#[cfg(target_os = "windows")]
263	fn enable_ansi(&self) -> bool {
264		enable_ansi_stdout()
265	}
266}
267
268impl ConsoleOutputFeatures for Stderr {
269	fn is_atty(&self) -> bool {
270		self.is_terminal()
271	}
272
273	/// Only windows terminals have to have ansi ENABLED.
274	#[cfg(not(target_os = "windows"))]
275	fn enable_ansi(&self) -> bool {
276		true
277	}
278
279	#[cfg(target_os = "windows")]
280	fn enable_ansi(&self) -> bool {
281		enable_ansi_stderr()
282	}
283}
284impl ConsoleOutputFeatures for StderrLock<'_> {
285	fn is_atty(&self) -> bool {
286		self.is_terminal()
287	}
288
289	/// Only windows terminals have to have ansi ENABLED.
290	#[cfg(not(target_os = "windows"))]
291	fn enable_ansi(&self) -> bool {
292		true
293	}
294
295	#[cfg(target_os = "windows")]
296	fn enable_ansi(&self) -> bool {
297		enable_ansi_stderr()
298	}
299}
300
301/// Implement [`ConsoleOutputFeatures`] for lots of types quickly....
302macro_rules! impl_default_output_features {
303	($type:ty) => {
304		impl ConsoleOutputFeatures for $type {
305			fn is_atty(&self) -> bool {
306				false
307			}
308
309			fn enable_ansi(&self) -> bool {
310				true
311			}
312		}
313	};
314}
315impl_default_output_features!(File);
316impl_default_output_features!(TcpStream);
317impl_default_output_features!(&mut [u8]);
318#[cfg(unix)]
319impl_default_output_features!(UnixStream);
320impl_default_output_features!(Arc<File>);
321impl_default_output_features!(Cursor<&mut [u8]>);
322impl_default_output_features!(Empty);
323impl_default_output_features!(PipeWriter);
324impl_default_output_features!(Sink);
325#[cfg(unix)]
326impl_default_output_features!(&'_ UnixStream);
327impl_default_output_features!(Cursor<&mut Vec<u8>>);
328impl_default_output_features!(Cursor<Box<[u8]>>);
329impl_default_output_features!(Cursor<Vec<u8>>);
330impl_default_output_features!(VecDeque<u8>);
331impl_default_output_features!(Vec<u8>);
332
333impl<Inner: ConsoleOutputFeatures> ConsoleOutputFeatures for Box<Inner> {
334	fn is_atty(&self) -> bool {
335		(*(*self)).is_atty()
336	}
337
338	fn enable_ansi(&self) -> bool {
339		(*(*self)).enable_ansi()
340	}
341}
342
343impl<Inner: ConsoleOutputFeatures + Write> ConsoleOutputFeatures for BufWriter<Inner> {
344	fn is_atty(&self) -> bool {
345		self.get_ref().is_atty()
346	}
347
348	fn enable_ansi(&self) -> bool {
349		self.get_ref().enable_ansi()
350	}
351}
352
353impl<Inner: ConsoleOutputFeatures + Write> ConsoleOutputFeatures for LineWriter<Inner> {
354	fn is_atty(&self) -> bool {
355		self.get_ref().is_atty()
356	}
357
358	fn enable_ansi(&self) -> bool {
359		self.get_ref().enable_ansi()
360	}
361}
362
363impl<const N: usize> ConsoleOutputFeatures for Cursor<[u8; N]> {
364	fn is_atty(&self) -> bool {
365		false
366	}
367
368	fn enable_ansi(&self) -> bool {
369		true
370	}
371}
372
373#[cfg(target_os = "windows")]
374#[must_use]
375fn enable_ansi_stdout() -> bool {
376	STDOUT_ANSI_INITIALIZER.call_once(|| {
377		// Some terminals on Windows like GitBash can't use WinAPI calls directly
378		// so when we try to enable the ANSI-flag for Windows this won't work.
379		// Because of that we should check first if the TERM-variable is set
380		// and see if the current terminal is a terminal who does support ANSI.
381		let vt_processing_result = {
382			if let Ok(handle) = unsafe { GetStdHandle(STD_OUTPUT_HANDLE) } {
383				let mask = ENABLE_VIRTUAL_TERMINAL_PROCESSING;
384				let mut current_console_mode = CONSOLE_MODE(0);
385				let was_success_get =
386					unsafe { GetConsoleMode(handle, &raw mut current_console_mode).is_ok() };
387
388				let mut was_success_set = false;
389				if was_success_get {
390					if current_console_mode.0 & mask.0 == 0 {
391						current_console_mode.0 |= mask.0;
392						was_success_set =
393							unsafe { SetConsoleMode(handle, current_console_mode).is_ok() };
394					} else {
395						was_success_set = true;
396					}
397				}
398
399				was_success_get && was_success_set
400			} else {
401				false
402			}
403		};
404
405		STDOUT_SUPPORTS_ANSI_ESCAPE_CODES.store(
406			vt_processing_result || env_var("TERM").is_ok_and(|term| term != "dumb"),
407			AtomicOrdering::SeqCst,
408		);
409	});
410
411	STDOUT_SUPPORTS_ANSI_ESCAPE_CODES.load(AtomicOrdering::SeqCst)
412}
413
414#[cfg(target_os = "windows")]
415#[must_use]
416fn enable_ansi_stderr() -> bool {
417	STDERR_ANSI_INITIALIZER.call_once(|| {
418		// Some terminals on Windows like GitBash can't use WinAPI calls directly
419		// so when we try to enable the ANSI-flag for Windows this won't work.
420		// Because of that we should check first if the TERM-variable is set
421		// and see if the current terminal is a terminal who does support ANSI.
422		let vt_processing_result = {
423			if let Ok(handle) = unsafe { GetStdHandle(STD_ERROR_HANDLE) } {
424				let mask = ENABLE_VIRTUAL_TERMINAL_PROCESSING;
425				let mut current_console_mode = CONSOLE_MODE(0);
426				let was_success_get =
427					unsafe { GetConsoleMode(handle, &raw mut current_console_mode).is_ok() };
428
429				let mut was_success_set = false;
430				if was_success_get {
431					if current_console_mode.0 & mask.0 == 0 {
432						current_console_mode.0 |= mask.0;
433						was_success_set =
434							unsafe { SetConsoleMode(handle, current_console_mode).is_ok() };
435					} else {
436						was_success_set = true;
437					}
438				}
439
440				was_success_get && was_success_set
441			} else {
442				false
443			}
444		};
445
446		STDERR_SUPPORTS_ANSI_ESCAPE_CODES.store(
447			vt_processing_result || env_var("TERM").is_ok_and(|term| term != "dumb"),
448			AtomicOrdering::SeqCst,
449		);
450	});
451
452	STDERR_SUPPORTS_ANSI_ESCAPE_CODES.load(AtomicOrdering::SeqCst)
453}
454
455#[cfg(test)]
456mod unit_tests {
457	use super::*;
458
459	// More of a compilation test, rather than a runtime test.
460	#[test]
461	pub fn can_get_regex() {
462		_ = get_ansi_escape_code_regex();
463	}
464
465	#[test]
466	pub fn stdout_output_features() {
467		let stdout = std::io::stdout();
468
469		assert_eq!(
470			(&stdout as &dyn ConsoleOutputFeatures).is_atty(),
471			stdout.is_terminal(),
472			"Failed to check if stdout is a tty!",
473		);
474		// More of a compilation message.
475		_ = stdout.enable_ansi();
476
477		let locked = stdout.lock();
478		assert_eq!(
479			(&locked as &dyn ConsoleOutputFeatures).is_atty(),
480			locked.is_terminal(),
481			"Failed to check if stdout lock is a tty!",
482		);
483		// More of a compilation message.
484		_ = locked.enable_ansi();
485	}
486
487	#[test]
488	pub fn stderr_output_features() {
489		let stderr = std::io::stderr();
490
491		assert_eq!(
492			(&stderr as &dyn ConsoleOutputFeatures).is_atty(),
493			stderr.is_terminal(),
494			"Failed to check if stderr is a tty!",
495		);
496		// More of a compilation message.
497		_ = stderr.enable_ansi();
498
499		let locked = stderr.lock();
500		assert_eq!(
501			(&locked as &dyn ConsoleOutputFeatures).is_atty(),
502			locked.is_terminal(),
503			"Failed to check if stderr lock is a tty!",
504		);
505		// More of a compilation message.
506		_ = locked.enable_ansi();
507	}
508
509	#[test]
510	pub fn default_output_features() {
511		// File / Arc<File>
512		{
513			let temporary_file = tempfile::tempfile().expect("Failed to create temporary file!");
514			assert!(!temporary_file.is_atty());
515			assert!(temporary_file.enable_ansi());
516
517			let arc = Arc::new(temporary_file);
518			assert!(!arc.is_atty());
519			assert!(arc.enable_ansi());
520		}
521
522		// Vec<u8> / &mut [u8] / Cursor<Vec<u8>> / VecDequeue<u8>
523		{
524			let mut data: Vec<u8> = Vec::new();
525
526			assert!(!data.is_atty());
527			assert!(data.enable_ansi());
528
529			assert!(!(&mut data as &mut [u8]).is_atty());
530			assert!((&mut data as &mut [u8]).enable_ansi());
531
532			let cursor = Cursor::new(data);
533			assert!(!cursor.is_atty());
534			assert!(cursor.enable_ansi());
535
536			let dequeue: VecDeque<u8> = VecDeque::new();
537			assert!(!dequeue.is_atty());
538			assert!(dequeue.enable_ansi());
539		}
540
541		// Cursor<&mut [u8]> / Cursor<&mut Vec<u8>> / Cursor<Box<[u8]>>
542		{
543			let mut data: Vec<u8> = Vec::new();
544
545			{
546				let to_cursor: &mut [u8] = &mut data;
547				let cursor = Cursor::new(to_cursor);
548
549				assert!(!cursor.is_atty());
550				assert!(cursor.enable_ansi());
551			}
552
553			{
554				let cursor: Cursor<&mut Vec<u8>> = Cursor::new(&mut data);
555				assert!(!cursor.is_atty());
556				assert!(cursor.enable_ansi());
557			}
558
559			{
560				let cursor: Cursor<Box<[u8]>> = Cursor::new(data.into_boxed_slice());
561				assert!(!cursor.is_atty());
562				assert!(cursor.enable_ansi());
563			}
564		}
565
566		{
567			let empty = std::io::empty();
568			assert!(!empty.is_atty());
569			assert!(empty.enable_ansi());
570		}
571	}
572}