rm-lisa 0.3.2

A logging library for rem-verse, with support for inputs, tasks, and more.
Documentation
//! Handle character replacements.
//!
//! This is the actual implementation of 'custom keybinds', which allow
//! remapping key commands. Although I would like to accept more flexibility
//! here, doing so can be difficult, and this mostly follows `stty` rules for
//! remapping (e.g. can't remap multi-character sequences).
//!
//! The following key codes are supported, and can be specified on both sides:
//!
//! - `a-z`, `A-Z`: normal keys that you may press, or may press with shift.
//! - `^a-z`: Ctrl + a-z
//! - `0xN`: the raw utf-8 byte value in hex of a character to replace.
//! - `0N`: the raw utf-8 byte value in octal of a character to replace.
//! - `N`: the raw utf-8 byte value in decimal of a character to replace.
//! - `undef`, `^-`: used to unbind a particular key.
//!
//! For example, let's say you want to *replace* ctrl-c with ctrl-z, and allow
//! `ctrl-d` to also be invoked with `ctrl-q`. You would specify the following
//! string: `^c=undef,^z=^c,^q=^d`. This is three values separated by comma, and
//! can be specified in any order.

use crate::errors::LisaError;
use fnv::FnvHashMap;
use std::hash::BuildHasherDefault;

/// Parse multiple character replacements into a list of all the character
/// replacements to apply at once.
///
/// ## Errors
///
/// If we cannot parse any of the character replacements after trimming, or we
/// try to replace the `undef` character with something else.
///
/// ```
/// # use fnv::FnvHashMap;
/// # use rm_lisa::input::stdin::replacement::parse_character_replacements;
/// let mut hash_map = FnvHashMap::default();
/// hash_map.insert('\u{3}', None);
/// hash_map.insert('\u{1a}', Some('\u{3}'));
/// hash_map.insert('\u{11}', Some('\u{4}'));
/// assert_eq!(
///   parse_character_replacements("^c=undef,^z=^c,^q=^d").expect("failed to parse real character replacements"),
///   hash_map,
/// );
/// ```
pub fn parse_character_replacements(
	replacements: &str,
) -> Result<FnvHashMap<char, Option<char>>, LisaError> {
	if replacements.trim().is_empty() {
		return Ok(FnvHashMap::with_capacity_and_hasher(
			0,
			BuildHasherDefault::default(),
		));
	}

	let mut final_replacements = FnvHashMap::default();
	for replacement_object in replacements.split(',') {
		let trimmed = replacement_object.trim();
		if trimmed.is_empty() {
			continue;
		}
		let Some((key, value)) = trimmed.split_once('=') else {
			return Err(LisaError::RemapError(trimmed.to_owned()));
		};
		let Some(replacement) = remap_to_character(key)? else {
			return Err(LisaError::RemapError(trimmed.to_owned()));
		};
		let replace_with = remap_to_character(value)?;
		final_replacements.insert(replacement, replace_with);
	}

	Ok(final_replacements)
}

/// Convert a 'remap' character into an actual optional character.
///
/// ## Errors
///
/// If this character is not a re-mappable character. It is some random string
/// data.
///
/// ## Examples
///
/// An example of every single remap-ing type that can exist:
///
/// ```
/// # use rm_lisa::input::stdin::replacement::remap_to_character;
/// assert_eq!(remap_to_character("a").expect("Failed to remap?"), Some('a'));
/// assert_eq!(remap_to_character("Z").expect("Failed to remap?"), Some('Z'));
/// assert_eq!(remap_to_character("^c").expect("Failed to remap?"), Some('\u{3}'));
/// assert_eq!(remap_to_character("0x69").expect("Failed to remap?"), Some('\u{69}'));
/// assert_eq!(remap_to_character("042").expect("Failed to remap?"), Some('\u{22}'));
/// assert_eq!(remap_to_character("920").expect("Failed to remap?"), Some('Θ'));
/// assert_eq!(remap_to_character("^-").expect("Failed to remap?"), None);
/// assert_eq!(remap_to_character("undef").expect("Failed to remap?"), None);
/// ```
pub fn remap_to_character(data: &str) -> Result<Option<char>, LisaError> {
	if data.is_empty() {
		return Err(LisaError::RemapError(data.to_owned()));
	}
	if data == "undef" {
		return Ok(None);
	}

	let character_amount = data.chars().count();
	let first_character = data.chars().next().unwrap_or_default();
	if character_amount == 1 {
		if first_character.is_ascii_lowercase() {
			return Ok(Some(first_character));
		}
		if first_character.is_ascii_uppercase() {
			return Ok(Some(first_character));
		}
	} else if character_amount == 2 && first_character == '^' {
		let second_character = data.chars().nth(1).unwrap_or_default();

		if second_character.is_ascii_lowercase() {
			return Ok(char::from_u32((second_character as u32 - 'a' as u32) + 1));
		} else if second_character.is_ascii_uppercase() {
			return Ok(char::from_u32((second_character as u32 - 'A' as u32) + 1));
		} else if second_character == '-' {
			return Ok(None);
		}

		return Err(LisaError::RemapError(data.to_owned()));
	}

	if first_character == '0' {
		// Parse as u16 first as it starts with '0x'
		let second_char = data.chars().nth(1).unwrap_or_default();
		if second_char == 'x'
			&& let Ok(parsed) = u32::from_str_radix(&data[2..], 16)
		{
			return Ok(char::from_u32(parsed));
		} else if let Ok(value) = u32::from_str_radix(&data[1..], 8) {
			return Ok(char::from_u32(value));
		}
	}
	if let Ok(number) = data.parse::<u32>() {
		return Ok(char::from_u32(number));
	}

	Err(LisaError::RemapError(data.to_owned()))
}

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

	#[test]
	pub fn remap_grabbag_strings() {
		for (string, expected_value) in [
			("", None),
			("undef", Some(None)),
			("a", Some(Some('a'))),
			("A", Some(Some('A'))),
			("-", None),
			("^a", Some(Some('\u{1}'))),
			("^Z", Some(Some('\u{1a}'))),
			("^-", Some(None)),
			("^$", None),
			("abcdefghijklmnopqrstuvwxyz", None),
			("0x10", Some(Some('\u{10}'))),
			("010", Some(Some('\u{8}'))),
			("9", Some(Some('\u{9}'))),
			("0x99999999999999999999999999999999999999", None),
			("099999999999999999999999999999999999999", None),
		] {
			assert_eq!(remap_to_character(string).ok(), expected_value);
		}
	}
}