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: alphanumerics (Unicode), '.', '-', '_', ' ', '(', ')', '+'"
)]
InvalidChar(String, char),
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ThemeName(String);
impl ThemeName {
pub fn parse(s: &str) -> Result<Self, ThemeNameError> {
let mut chars = s.chars();
let Some(first) = chars.next() else {
return Err(ThemeNameError::Empty);
};
if first.is_whitespace() {
return Err(ThemeNameError::InvalidChar(s.to_string(), first));
}
let last = s.chars().next_back().unwrap_or(first);
if last.is_whitespace() {
return Err(ThemeNameError::InvalidChar(s.to_string(), last));
}
if let Some(c) = s.chars().find(|c| !is_allowed(*c)) {
return Err(ThemeNameError::InvalidChar(s.to_string(), c));
}
Ok(Self(s.to_string()))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
fn is_allowed(c: char) -> bool {
c.is_alphanumeric() || matches!(c, '.' | '-' | '_' | ' ' | '(' | ')' | '+')
}
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("foo\\bar"),
Err(ThemeNameError::InvalidChar(_, '\\'))
));
assert!(matches!(
ThemeName::parse("~/themes"),
Err(ThemeNameError::InvalidChar(_, '~'))
));
}
#[test]
fn accepts_gogh_style_names() {
assert_eq!(ThemeName::parse("3024 Day").unwrap().as_str(), "3024 Day");
assert_eq!(
ThemeName::parse("Flatland (Palenight)").unwrap().as_str(),
"Flatland (Palenight)"
);
assert!(ThemeName::parse("VS Code Dark+").is_ok());
assert!(ThemeName::parse("Catppuccin Frappé").is_ok());
assert!(ThemeName::parse("테마").is_ok());
}
#[test]
fn rejects_leading_or_trailing_whitespace() {
assert!(matches!(
ThemeName::parse(" leading"),
Err(ThemeNameError::InvalidChar(_, ' '))
));
assert!(matches!(
ThemeName::parse("trailing "),
Err(ThemeNameError::InvalidChar(_, ' '))
));
assert!(matches!(
ThemeName::parse("\tlead-tab"),
Err(ThemeNameError::InvalidChar(_, '\t'))
));
}
#[test]
fn rejects_interior_tab_and_quotes() {
assert!(matches!(
ThemeName::parse("name\tx"),
Err(ThemeNameError::InvalidChar(_, '\t'))
));
assert!(matches!(
ThemeName::parse("\"quoted\""),
Err(ThemeNameError::InvalidChar(_, '"'))
));
}
}