rm-lisa 0.3.2

A logging library for rem-verse, with support for inputs, tasks, and more.
Documentation
//! A series of string helpers that are used in various parts of color
//! rendering.

use crate::{
	display::{renderers::get_ansi_escape_code_regex, tracing::SuperConsoleLogMessage},
	errors::LisaError,
};
use fnv::FnvHasher;
use owo_colors::{AnsiColors, DynColors, OwoColorize};
use std::{
	fmt::Write,
	hash::{Hash, Hasher},
	str::FromStr,
};
use tracing::Level;
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};

/// The "ESC" control character that can be combined to create a control
/// sequence initiator.
///
/// See: <https://en.wikipedia.org/wiki/ANSI_escape_code> for more details.
const ESC: char = '\x1B';
/// Whenever we need to render an empty header we use this constant, as it is
/// not affected by terminal size, and always the same size.
pub const EMPTY_HEADER: &str = "            |";
/// A set of default color choices to use when a user doesn't specify a custom color.
static COLOR_CHOICES: &[AnsiColors] = &[
	AnsiColors::BrightGreen,
	AnsiColors::BrightRed,
	AnsiColors::BrightCyan,
	AnsiColors::BrightYellow,
	AnsiColors::BrightMagenta,
	AnsiColors::BrightBlue,
	AnsiColors::Green,
	AnsiColors::Red,
	AnsiColors::Cyan,
	AnsiColors::Yellow,
	AnsiColors::Magenta,
	AnsiColors::Blue,
];

/// The header width, constant size.
///
/// In this case the length of the header in bytes is the same as the
/// as the unicode character width.
#[allow(clippy::inline_always)]
#[inline(always)]
#[must_use]
pub const fn header_width() -> usize {
	EMPTY_HEADER.len()
}

/// Create a header for a log message.
///
/// The header is the first part of the log message or what gets rendered on
/// the 'left' side of the log message. Log messages are rendered in the
/// following format: `${header}|${message}|${tailer}`.
pub fn create_header(
	app_name: &'static str,
	log: &SuperConsoleLogMessage,
) -> Result<String, LisaError> {
	let mut header = String::with_capacity(header_width());

	let subsystem = log.subsytem().unwrap_or(app_name);
	let subsystem_width = subsystem.width();
	let mut padding_needing = 6_usize.saturating_sub(subsystem_width);
	while padding_needing > 0 {
		header.push(' ');
		padding_needing -= 1;
	}

	if subsystem_width < 6 {
		write!(
			&mut header,
			"{}/",
			subsystem.color(color_for_subsystem(subsystem, log.color())),
		)?;
	} else {
		let mut short_subsystem = String::with_capacity(6);
		let mut current_width = 0_usize;

		for char in subsystem.chars() {
			let char_width = char.width().unwrap_or_default();
			if char_width + current_width > 3 {
				break;
			}
			short_subsystem.push(char);
			current_width += char_width;
		}
		while current_width < 6 {
			short_subsystem.push('.');
			current_width += 1;
		}

		write!(
			&mut header,
			"{}/",
			subsystem.color(color_for_subsystem(subsystem, log.color())),
		)?;
	}

	write!(
		&mut header,
		"{}",
		match *log.level() {
			Level::ERROR => format!("{}", "ERROR".red().bold()),
			Level::WARN => format!("{}", "WARN ".bright_red().bold()),
			Level::INFO => format!("{}", "INFO ".white().bold()),
			Level::DEBUG => format!("{}", "DEBUG".cyan().bold()),
			Level::TRACE => format!("{}", "TRACE".magenta().bold()),
		},
	)?;
	header.push('|');

	Ok(header)
}

/// Calculate the message width based on terminal size.
///
/// This always assumes that the terminal width is at least 40 characters wide,
/// as our normalized terminal width is.
#[must_use]
pub const fn calculate_message_width(terminal_width: u16) -> usize {
	debug_assert!(
		terminal_width >= 40,
		"terminal width below 40? this should never happen!"
	);

	(terminal_width as usize) - 13_usize - calculate_tailer_width(terminal_width)
}

/// Calculate the tailer width based on the terminal size.
///
/// This always assumes that the terminal width is at least 40 characters wide,
/// as our normalized terminal width is.
#[must_use]
pub const fn calculate_tailer_width(terminal_width: u16) -> usize {
	debug_assert!(
		terminal_width >= 40,
		"terminal width below 40? this should never happen!"
	);

	(1 + (10 * (terminal_width / 40))) as usize
}

/// Move the terminal cursor in a particular direction until it hits an edge.
///
/// This cannot move the cursor past any of the terminal boundaries, and will stop
/// moving after a certain point.
#[must_use]
pub fn move_cursor(direction: CursorDirection, width: usize) -> String {
	if width == 0 {
		return String::with_capacity(0);
	}

	let formatted = format!("{width}");
	let mut result = String::with_capacity(3 + formatted.len());
	result.push(ESC);
	result.push('[');
	result.push_str(&formatted);
	result.push(match direction {
		CursorDirection::Up => 'A',
		CursorDirection::Down => 'B',
		CursorDirection::Left => 'D',
		CursorDirection::Right => 'C',
	});
	result
}

/// Erase a line using ANSI ESCAPE codes.
#[must_use]
pub fn erase_line(config: ClearLine) -> String {
	let mut result = String::with_capacity(4);
	result.push(ESC);
	result.push('[');
	result.push(match config {
		ClearLine::CursorToBeginning => '1',
		ClearLine::CursorToEnd => '0',
		ClearLine::EntireLine => '2',
	});
	result.push('K');
	result
}

/// Pad a string to a particular character width.
///
/// This does utilize [`unicode_width`] to identify _how_ wide a character
/// is.
#[must_use]
pub fn pad_to_width(mut to_pad: String, ensure: usize) -> String {
	let mut owned = None;
	if to_pad.contains('\x1B') {
		owned = Some(get_ansi_escape_code_regex().replace_all(&to_pad, ""));
	}
	let mut current_character_width = owned
		.as_deref()
		.unwrap_or(&to_pad)
		.chars()
		.map(|character| character.width().unwrap_or_default())
		.sum::<usize>();
	while current_character_width < ensure {
		to_pad.push(' ');
		current_character_width += 1;
	}
	to_pad
}

/// Chunk a string of N characters into 1, or more strings of M characters.
pub fn chunk_string_into_width(target_message_width: usize, message: &str) -> Vec<String> {
	let mut messages = Vec::new();
	let mut current_buff = String::with_capacity(target_message_width);

	let mut current_width = 0_usize;

	let mut skip_amount = 0_usize;
	let mut iterator = message.chars().peekable();
	while let Some(character) = iterator.next() {
		let character_width = character.width().unwrap_or_default();

		// Properly handle Control Sequences which don't get rendered.
		if character == ESC {
			// We're starting a CSI.
			if iterator.peek() == Some(&'[') {
				let mut csi_iterator = iterator.clone();
				_ = csi_iterator.next();
				// One for CSI one for escape.
				skip_amount += 2;

				let mut parameter_bytes_valid = true;
				for character in csi_iterator {
					let raw = character as u32;

					if (0x30_u32..=0x3F_u32).contains(&raw) && parameter_bytes_valid {
						skip_amount += 1;
					} else if (0x20_u32..=0x2F_u32).contains(&raw) {
						parameter_bytes_valid = false;
						skip_amount += 1;
					} else if (0x40_u32..=0x7E_u32).contains(&raw) {
						skip_amount += 1;
						break;
					} else {
						skip_amount = 0;
						break;
					}
				}
			}
		}

		if skip_amount > 0 {
			current_buff.push(character);
			skip_amount -= 1;
			continue;
		}

		if character == '\n' {
			messages.push(pad_to_width(current_buff, target_message_width));
			current_buff = String::with_capacity(target_message_width);
			current_width = 0;
		} else if character == '\r' || character == '' {
			// IGNORE...
		} else {
			// Wrap when necessary.
			if current_width + character_width >= target_message_width {
				messages.push(pad_to_width(current_buff, target_message_width));
				current_buff = String::with_capacity(target_message_width);
				current_width = 0;
			}
			current_buff.push(character);
			current_width += character_width;
		}
	}
	if !current_buff.is_empty() {
		messages.push(pad_to_width(current_buff, target_message_width));
	}

	messages
}

/// A cursor direction in the terminal.
#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum CursorDirection {
	Up,
	Down,
	Left,
	Right,
}

/// Ways that you can clear a particular terminal line.
#[allow(unused)]
#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ClearLine {
	/// Clear from the cursor to the beginning of the line.
	CursorToBeginning,
	/// Clear from the cursor to the end of the line.
	CursorToEnd,
	/// Clear the entire line (DOES NOT CHANGE CURSOR POSITION).
	EntireLine,
}

#[must_use]
fn color_for_subsystem(subsystem: &str, color: Option<&str>) -> DynColors {
	if let Some(explicit_color) = color
		&& let Ok(explicit) = DynColors::from_str(explicit_color)
	{
		return explicit;
	}

	let mut hasher = FnvHasher::with_key(69420);
	subsystem.hash(&mut hasher);
	// Find a color choice based off the hash of the message...
	let idx =
		usize::try_from(hasher.finish() % u64::try_from(COLOR_CHOICES.len()).unwrap_or(u64::MIN))
			.unwrap_or(usize::MIN);

	DynColors::Ansi(COLOR_CHOICES[idx])
}

#[cfg(test)]
mod unit_tests {
	use super::*;

	#[test]
	pub fn calculate_tailer_sizes_at_widths() {
		assert_eq!(calculate_tailer_width(40), 11);
		assert_eq!(calculate_tailer_width(80), 21);
		assert_eq!(calculate_tailer_width(100), 21);
		assert_eq!(calculate_tailer_width(120), 31);
	}

	#[test]
	pub fn calculate_message_sizes_at_widths() {
		assert_eq!(calculate_message_width(40), 16);
		assert_eq!(calculate_message_width(80), 46);
		assert_eq!(calculate_message_width(100), 66);
		assert_eq!(calculate_message_width(120), 76);
	}
}