use serde::{Deserialize, Serialize};
use std::{collections::BTreeMap, fs, path::Path, str::FromStr};
#[derive(Debug, Clone)]
pub enum ThemeError {
NotFound(String),
InvalidJson(String),
FileNotFound(String),
FileReadError(String),
}
impl std::fmt::Display for ThemeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ThemeError::NotFound(name) => write!(f, "theme '{name}' not found"),
ThemeError::InvalidJson(msg) => write!(f, "invalid theme json: {msg}"),
ThemeError::FileNotFound(path) => write!(f, "theme file not found: {path}"),
ThemeError::FileReadError(msg) => write!(f, "failed to read theme file: {msg}"),
}
}
}
impl std::error::Error for ThemeError {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ThemeParseError(String);
impl std::fmt::Display for ThemeParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "unknown theme: {}", self.0)
}
}
impl std::error::Error for ThemeParseError {}
impl From<std::io::Error> for ThemeError {
fn from(err: std::io::Error) -> Self {
if err.kind() == std::io::ErrorKind::NotFound {
ThemeError::FileNotFound(err.to_string())
} else {
ThemeError::FileReadError(err.to_string())
}
}
}
impl From<serde_json::Error> for ThemeError {
fn from(err: serde_json::Error) -> Self {
ThemeError::InvalidJson(err.to_string())
}
}
#[derive(Default, Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum UnderlineStyle {
#[default]
None,
Solid,
Wavy,
Double,
Dotted,
Dashed,
}
#[derive(Default, Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
pub struct TextDecoration {
#[serde(default)]
pub underline: UnderlineStyle,
#[serde(default)]
pub strikethrough: bool,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Appearance {
Light,
#[default]
Dark,
}
impl std::fmt::Display for Appearance {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Appearance::Light => write!(f, "light"),
Appearance::Dark => write!(f, "dark"),
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
pub struct Theme {
pub name: String,
pub appearance: Appearance,
pub revision: String,
pub highlights: BTreeMap<String, Style>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Style {
pub fg: Option<String>,
pub bg: Option<String>,
pub bold: bool,
pub italic: bool,
pub text_decoration: TextDecoration,
}
fn deserialize_underline_field<'de, D>(deserializer: D) -> Result<UnderlineStyle, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de;
struct UnderlineVisitor;
impl<'de> de::Visitor<'de> for UnderlineVisitor {
type Value = UnderlineStyle;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a boolean or underline style string")
}
fn visit_bool<E: de::Error>(self, v: bool) -> Result<UnderlineStyle, E> {
Ok(if v {
UnderlineStyle::Solid
} else {
UnderlineStyle::None
})
}
fn visit_str<E: de::Error>(self, v: &str) -> Result<UnderlineStyle, E> {
match v {
"solid" => Ok(UnderlineStyle::Solid),
"wavy" | "undercurl" => Ok(UnderlineStyle::Wavy),
"double" => Ok(UnderlineStyle::Double),
"dotted" => Ok(UnderlineStyle::Dotted),
"dashed" => Ok(UnderlineStyle::Dashed),
"none" => Ok(UnderlineStyle::None),
_ => Err(de::Error::unknown_variant(
v,
&["solid", "wavy", "double", "dotted", "dashed", "none"],
)),
}
}
}
deserializer.deserialize_any(UnderlineVisitor)
}
#[derive(Deserialize)]
struct StyleHelper {
#[serde(default)]
fg: Option<String>,
#[serde(default)]
bg: Option<String>,
#[serde(default)]
bold: bool,
#[serde(default)]
italic: bool,
#[serde(default, deserialize_with = "deserialize_underline_field")]
underline: UnderlineStyle,
#[serde(default)]
undercurl: bool,
#[serde(default)]
underdouble: bool,
#[serde(default)]
underdotted: bool,
#[serde(default)]
underdashed: bool,
#[serde(default)]
strikethrough: bool,
}
impl<'de> Deserialize<'de> for Style {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let helper = StyleHelper::deserialize(deserializer)?;
let underline = if helper.undercurl {
UnderlineStyle::Wavy
} else if helper.underdouble {
UnderlineStyle::Double
} else if helper.underdotted {
UnderlineStyle::Dotted
} else if helper.underdashed {
UnderlineStyle::Dashed
} else {
helper.underline
};
Ok(Style {
fg: helper.fg,
bg: helper.bg,
bold: helper.bold,
italic: helper.italic,
text_decoration: TextDecoration {
underline,
strikethrough: helper.strikethrough,
},
})
}
}
impl Serialize for Style {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
use serde::ser::SerializeStruct;
let mut count = 0;
if self.fg.is_some() {
count += 1;
}
if self.bg.is_some() {
count += 1;
}
if self.bold {
count += 1;
}
if self.italic {
count += 1;
}
if self.text_decoration.underline != UnderlineStyle::None {
count += 1;
}
if self.text_decoration.strikethrough {
count += 1;
}
let mut state = serializer.serialize_struct("Style", count)?;
if let Some(fg) = &self.fg {
state.serialize_field("fg", fg)?;
}
if let Some(bg) = &self.bg {
state.serialize_field("bg", bg)?;
}
if self.bold {
state.serialize_field("bold", &true)?;
}
if self.italic {
state.serialize_field("italic", &true)?;
}
match self.text_decoration.underline {
UnderlineStyle::None => {}
UnderlineStyle::Solid => state.serialize_field("underline", &true)?,
UnderlineStyle::Wavy => state.serialize_field("undercurl", &true)?,
UnderlineStyle::Double => state.serialize_field("underdouble", &true)?,
UnderlineStyle::Dotted => state.serialize_field("underdotted", &true)?,
UnderlineStyle::Dashed => state.serialize_field("underdashed", &true)?,
}
if self.text_decoration.strikethrough {
state.serialize_field("strikethrough", &true)?;
}
state.end()
}
}
pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Theme, ThemeError> {
let path = path.as_ref();
let json = fs::read_to_string(path).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
ThemeError::FileNotFound(path.display().to_string())
} else {
ThemeError::FileReadError(e.to_string())
}
})?;
from_json(&json)
}
pub fn from_json(json: &str) -> Result<Theme, ThemeError> {
let theme: Theme = serde_json::from_str(json)?;
if theme.name.is_empty() {
return Err(ThemeError::InvalidJson("theme name cannot be empty".into()));
}
if theme.revision.is_empty() {
return Err(ThemeError::InvalidJson(
"theme revision cannot be empty".into(),
));
}
Ok(theme)
}
impl FromStr for Theme {
type Err = ThemeParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
get(s).map_err(|_| ThemeParseError(s.to_string()))
}
}
impl Theme {
pub fn new(
name: String,
appearance: Appearance,
revision: String,
highlights: BTreeMap<String, Style>,
) -> Self {
Theme {
name,
appearance,
revision,
highlights,
}
}
pub fn css(&self, enable_italic: bool) -> String {
let mut rules = Vec::new();
rules.push(format!(
"/* {}\n * revision: {}\n */\n\npre.lumis",
self.name, self.revision
));
if let Some(pre_style) = &self.pre_style("\n ") {
rules.push(format!(" {{\n {pre_style}\n}}\n"));
} else {
rules.push(" {}\n".to_string());
}
for (scope, style) in &self.highlights {
let style_css = style.css(enable_italic, "\n ");
if !style_css.is_empty() {
rules.push(format!(
".{} {{\n {}\n}}\n",
scope.replace('.', "-"),
style_css
))
};
}
rules.join("")
}
pub fn get_style(&self, scope: &str) -> Option<&Style> {
let mut current = scope;
loop {
if let Some(style) = self.highlights.get(current) {
return Some(style);
}
match current.rsplit_once('.') {
Some((parent, _)) => current = parent,
None => return None,
}
}
}
pub fn fg(&self) -> Option<&str> {
self.get_style("normal").and_then(|s| s.fg.as_deref())
}
pub fn bg(&self) -> Option<&str> {
self.get_style("normal").and_then(|s| s.bg.as_deref())
}
pub fn pre_style(&self, separator: &str) -> Option<String> {
let mut rules = Vec::new();
if let Some(fg) = self.fg() {
rules.push(format!("color: {fg};"));
}
if let Some(bg) = self.bg() {
rules.push(format!("background-color: {bg};"));
}
if rules.is_empty() {
None
} else {
Some(rules.join(separator))
}
}
}
impl Style {
pub fn css(&self, enable_italic: bool, separator: &str) -> String {
let mut rules = Vec::new();
if let Some(fg) = &self.fg {
rules.push(format!("color: {fg};"))
};
if let Some(bg) = &self.bg {
rules.push(format!("background-color: {bg};"))
};
if self.bold {
rules.push("font-weight: bold;".to_string())
}
if enable_italic && self.italic {
rules.push("font-style: italic;".to_string())
};
let td = &self.text_decoration;
let underline_css = match td.underline {
UnderlineStyle::None => None,
UnderlineStyle::Solid => Some("underline"),
UnderlineStyle::Wavy => Some("underline wavy"),
UnderlineStyle::Double => Some("underline double"),
UnderlineStyle::Dotted => Some("underline dotted"),
UnderlineStyle::Dashed => Some("underline dashed"),
};
match (underline_css, td.strikethrough) {
(Some(u), true) => rules.push(format!("text-decoration: {u} line-through;")),
(Some(u), false) => rules.push(format!("text-decoration: {u};")),
(None, true) => rules.push("text-decoration: line-through;".to_string()),
(None, false) => (),
};
rules.join(separator)
}
}
include!(concat!(env!("OUT_DIR"), "/theme_data.rs"));
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn test_available_themes() {
let themes: Vec<_> = available_themes().collect();
assert!(!themes.is_empty());
let dracula = themes.iter().find(|t| t.name == "dracula").unwrap();
assert_eq!(dracula.name, "dracula");
assert_eq!(dracula.appearance, Appearance::Dark);
let github_light = themes.iter().find(|t| t.name == "github_light").unwrap();
assert_eq!(github_light.name, "github_light");
assert_eq!(github_light.appearance, Appearance::Light);
for theme in themes {
assert!(!theme.name.is_empty());
assert!(theme.appearance == Appearance::Light || theme.appearance == Appearance::Dark);
}
}
#[test]
fn test_load_all_themes() {
for theme in ALL_THEMES.iter() {
assert!(!theme.name.is_empty());
}
let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
let expected_count = std::fs::read_dir(manifest_dir.join("themes"))
.unwrap()
.filter_map(|entry| entry.ok())
.filter(|entry| entry.path().extension().and_then(|s| s.to_str()) == Some("json"))
.count();
assert_eq!(ALL_THEMES.len(), expected_count);
}
#[test]
fn test_get_by_name() {
let theme = get("github_light").expect("Theme not found");
assert_eq!(theme.name, "github_light");
let err = get("non_existent_theme");
assert!(err.is_err());
}
#[test]
fn test_from_json() {
let json = r#"{"name": "test", "appearance": "dark", "revision": "3e976b4", "highlights": {"keyword": {"fg": "blue"}}}"#;
let theme = from_json(json).unwrap();
assert_eq!(theme.name, "test");
assert_eq!(
theme.get_style("keyword"),
Some(&Style {
fg: Some("blue".to_string()),
..Default::default()
})
);
}
#[test]
fn test_from_file() {
let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
let path = manifest_dir.join("themes/catppuccin_frappe.json");
let theme = from_file(&path).unwrap();
assert_eq!(theme.name, "catppuccin_frappe");
assert_eq!(
theme.get_style("tag.attribute"),
Some(&Style {
fg: Some("#e5c890".to_string()),
italic: true,
..Default::default()
})
);
}
#[test]
fn test_style_css() {
let style = Style {
fg: Some("blue".to_string()),
italic: true,
text_decoration: TextDecoration {
underline: UnderlineStyle::Solid,
strikethrough: false,
},
..Default::default()
};
assert_eq!(
style.css(true, " "),
"color: blue; font-style: italic; text-decoration: underline;"
);
}
#[test]
fn test_theme_css() {
let json = r#"{"name": "test", "appearance": "dark", "revision": "3e976b4", "highlights": {"normal": {"fg": "red", "bg": "green"}, "keyword": {"fg": "blue", "italic": true}, "tag.attribute": {"bg": "gray", "bold": true}}}"#;
let theme = from_json(json).unwrap();
let expected = r#"/* test
* revision: 3e976b4
*/
pre.lumis {
color: red;
background-color: green;
}
.keyword {
color: blue;
font-style: italic;
}
.normal {
color: red;
background-color: green;
}
.tag-attribute {
background-color: gray;
font-weight: bold;
}
"#;
assert_eq!(theme.css(true), expected);
}
#[test]
fn test_get_style_specialized() {
let json = r##"{
"name": "test",
"appearance": "dark",
"revision": "test",
"highlights": {
"comment": {"fg": "#666666"},
"comment.lua": {"fg": "#888888", "italic": true},
"markup.heading.2": {"fg": "#ff0000", "bold": true},
"markup.heading.2.markdown": {"fg": "#00ff00", "bold": true}
}
}"##;
let theme = from_json(json).unwrap();
let lua_comment = theme.get_style("comment.lua");
assert_eq!(
lua_comment,
Some(&Style {
fg: Some("#888888".to_string()),
italic: true,
..Default::default()
})
);
let md_heading = theme.get_style("markup.heading.2.markdown");
assert_eq!(
md_heading,
Some(&Style {
fg: Some("#00ff00".to_string()),
bold: true,
..Default::default()
})
);
}
#[test]
fn test_get_style_fallback_to_generic() {
let json = r##"{
"name": "test",
"appearance": "dark",
"revision": "test",
"highlights": {
"comment": {"fg": "#666666"},
"markup.heading.2": {"fg": "#ff0000", "bold": true}
}
}"##;
let theme = from_json(json).unwrap();
let rust_comment = theme.get_style("comment.rust");
assert_eq!(
rust_comment,
Some(&Style {
fg: Some("#666666".to_string()),
..Default::default()
})
);
let md_heading = theme.get_style("markup.heading.2.markdown");
assert_eq!(
md_heading,
Some(&Style {
fg: Some("#ff0000".to_string()),
bold: true,
..Default::default()
})
);
}
#[test]
fn test_get_style_fallback_to_parent_scope() {
let json = r##"{
"name": "test",
"appearance": "dark",
"revision": "test",
"highlights": {
"markup.heading": {"fg": "#ff0000", "bold": true}
}
}"##;
let theme = from_json(json).unwrap();
let md_heading = theme.get_style("markup.heading.2.markdown");
assert_eq!(
md_heading,
Some(&Style {
fg: Some("#ff0000".to_string()),
bold: true,
..Default::default()
})
);
}
#[test]
fn test_get_style_no_match() {
let json = r##"{
"name": "test",
"appearance": "dark",
"revision": "test",
"highlights": {
"keyword": {"fg": "#ff0000"}
}
}"##;
let theme = from_json(json).unwrap();
let result = theme.get_style("comment.rust");
assert!(result.is_none());
}
#[test]
fn test_get_style_prefers_specialized_over_generic() {
let json = r##"{
"name": "test",
"appearance": "dark",
"revision": "test",
"highlights": {
"string.special.symbol": {"fg": "#aaaaaa"},
"string.special.symbol.ruby": {"fg": "#bbbbbb", "bold": true},
"string.special.symbol.elixir": {"fg": "#cccccc", "italic": true}
}
}"##;
let theme = from_json(json).unwrap();
let ruby_symbol = theme.get_style("string.special.symbol.ruby");
assert_eq!(
ruby_symbol,
Some(&Style {
fg: Some("#bbbbbb".to_string()),
bold: true,
..Default::default()
})
);
let elixir_symbol = theme.get_style("string.special.symbol.elixir");
assert_eq!(
elixir_symbol,
Some(&Style {
fg: Some("#cccccc".to_string()),
italic: true,
..Default::default()
})
);
let python_symbol = theme.get_style("string.special.symbol.python");
assert_eq!(
python_symbol,
Some(&Style {
fg: Some("#aaaaaa".to_string()),
..Default::default()
})
);
}
#[test]
fn test_get_style_real_world_scenario() {
let json = r##"{
"name": "test",
"appearance": "dark",
"revision": "test",
"highlights": {
"markup.heading": {"fg": "#ff6b6b"},
"markup.heading.1": {"fg": "#ff0000", "bold": true},
"markup.heading.2": {"fg": "#00ff00", "bold": true},
"markup.heading.2.markdown": {"fg": "#4ecdc4", "bold": true, "underline": true},
"string.special.symbol": {"fg": "#ffeaa7"},
"string.special.symbol.elixir": {"fg": "#fab1a0", "italic": true}
}
}"##;
let theme = from_json(json).unwrap();
let markdown_h2 = theme.get_style("markup.heading.2.markdown");
assert_eq!(
markdown_h2,
Some(&Style {
fg: Some("#4ecdc4".to_string()),
bold: true,
text_decoration: TextDecoration {
underline: UnderlineStyle::Solid,
strikethrough: false,
},
..Default::default()
})
);
let elixir_atom = theme.get_style("string.special.symbol.elixir");
assert_eq!(
elixir_atom,
Some(&Style {
fg: Some("#fab1a0".to_string()),
italic: true,
..Default::default()
})
);
let rust_symbol = theme.get_style("string.special.symbol.rust");
assert_eq!(
rust_symbol,
Some(&Style {
fg: Some("#ffeaa7".to_string()),
..Default::default()
})
);
}
}