use serde::Deserialize;
use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct HexColor(String);
impl HexColor {
pub fn parse(s: &str) -> Option<Self> {
if s.len() == 7 && s.starts_with('#') && s[1..].chars().all(|c| c.is_ascii_hexdigit()) {
Some(HexColor(s.to_ascii_lowercase()))
} else {
None
}
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, Error, PartialEq, Eq)]
pub enum ThemeNameError {
#[error("theme name must not be empty")]
Empty,
#[error(
"theme name {0:?} contains invalid character {1:?}; allowed: A-Z, a-z, 0-9, '.', '-', '_'"
)]
InvalidChar(String, char),
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ThemeName(String);
impl ThemeName {
pub fn parse(s: &str) -> Result<Self, ThemeNameError> {
if s.is_empty() {
return Err(ThemeNameError::Empty);
}
if let Some(c) = s
.chars()
.find(|c| !(c.is_ascii_alphanumeric() || *c == '.' || *c == '-' || *c == '_'))
{
return Err(ThemeNameError::InvalidChar(s.to_string(), c));
}
Ok(Self(s.to_string()))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for ThemeName {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl<'de> Deserialize<'de> for ThemeName {
fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
let s = String::deserialize(de)?;
ThemeName::parse(&s).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ThemeLayer {
pub(crate) fg: Option<HexColor>,
pub(crate) bg: Option<HexColor>,
pub(crate) cursor: Option<HexColor>,
pub(crate) palette: [Option<HexColor>; 16],
}
impl ThemeLayer {
pub fn merge(&mut self, other: &ThemeLayer) {
if let Some(v) = &other.fg {
self.fg = Some(v.clone());
}
if let Some(v) = &other.bg {
self.bg = Some(v.clone());
}
if let Some(v) = &other.cursor {
self.cursor = Some(v.clone());
}
for i in 0..16 {
if let Some(v) = &other.palette[i] {
self.palette[i] = Some(v.clone());
}
}
}
pub fn is_empty(&self) -> bool {
self.fg.is_none()
&& self.bg.is_none()
&& self.cursor.is_none()
&& self.palette.iter().all(|c| c.is_none())
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ParsedPalette {
pub(crate) layer: ThemeLayer,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ParsedRc {
pub(crate) extends: Option<ThemeName>,
pub(crate) extends_dark: Option<ThemeName>,
pub(crate) extends_light: Option<ThemeName>,
pub(crate) base: ThemeLayer,
pub(crate) dark: ThemeLayer,
pub(crate) light: ThemeLayer,
}
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub enum Mode {
Dark,
Light,
Unknown,
}
impl ParsedRc {
pub fn parent_for(&self, mode: Mode) -> Option<&ThemeName> {
let specific = match mode {
Mode::Dark => self.extends_dark.as_ref(),
Mode::Light => self.extends_light.as_ref(),
Mode::Unknown => None,
};
specific.or(self.extends.as_ref())
}
}
#[cfg(test)]
mod hex_color_tests {
use super::HexColor;
#[test]
fn normalizes_to_lowercase() {
let a = HexColor::parse("#AABBCC").unwrap();
let b = HexColor::parse("#aabbcc").unwrap();
assert_eq!(a.as_str(), "#aabbcc");
assert_eq!(a, b);
}
#[test]
fn accepts_mixed_case() {
let c = HexColor::parse("#AbCdEf").unwrap();
assert_eq!(c.as_str(), "#abcdef");
}
}
#[cfg(test)]
mod theme_name_tests {
use super::{ThemeName, ThemeNameError};
#[test]
fn accepts_alnum_dot_dash_underscore() {
assert_eq!(
ThemeName::parse("catppuccin-mocha").unwrap().as_str(),
"catppuccin-mocha"
);
assert!(ThemeName::parse("one_dark").is_ok());
assert!(ThemeName::parse("solarized.v2").is_ok());
assert!(ThemeName::parse("ayu123").is_ok());
}
#[test]
fn rejects_empty() {
assert_eq!(ThemeName::parse(""), Err(ThemeNameError::Empty));
}
#[test]
fn rejects_path_traversal_chars() {
assert!(matches!(
ThemeName::parse("../etc"),
Err(ThemeNameError::InvalidChar(_, '/'))
));
assert!(matches!(
ThemeName::parse("foo/bar"),
Err(ThemeNameError::InvalidChar(_, '/'))
));
assert!(matches!(
ThemeName::parse("~/themes"),
Err(ThemeNameError::InvalidChar(_, '~'))
));
}
#[test]
fn rejects_whitespace_and_quotes() {
assert!(matches!(
ThemeName::parse("name with space"),
Err(ThemeNameError::InvalidChar(_, ' '))
));
assert!(matches!(
ThemeName::parse("name\tx"),
Err(ThemeNameError::InvalidChar(_, '\t'))
));
assert!(matches!(
ThemeName::parse("\"quoted\""),
Err(ThemeNameError::InvalidChar(_, '"'))
));
}
#[test]
fn rejects_non_ascii() {
assert!(matches!(
ThemeName::parse("테마"),
Err(ThemeNameError::InvalidChar(_, _))
));
}
}