use serde::{Deserialize, Serialize};
use std::{collections::BTreeMap, fs, path::Path, str::FromStr};
use derive_builder::Builder;
#[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()))
}
}
static RAINBOW_BRACKET_FALLBACKS: std::sync::LazyLock<BTreeMap<&'static str, Style>> =
std::sync::LazyLock::new(|| {
BTreeMap::from([
(
"punctuation.bracket.rainbow.1",
Style {
fg: Some("#e06c75".to_string()),
..Default::default()
},
),
(
"punctuation.bracket.rainbow.2",
Style {
fg: Some("#e5c07b".to_string()),
..Default::default()
},
),
(
"punctuation.bracket.rainbow.3",
Style {
fg: Some("#61afef".to_string()),
..Default::default()
},
),
(
"punctuation.bracket.rainbow.4",
Style {
fg: Some("#d19a66".to_string()),
..Default::default()
},
),
(
"punctuation.bracket.rainbow.5",
Style {
fg: Some("#98c379".to_string()),
..Default::default()
},
),
(
"punctuation.bracket.rainbow.6",
Style {
fg: Some("#c678dd".to_string()),
..Default::default()
},
),
])
});
impl Theme {
pub fn new(
name: String,
appearance: Appearance,
revision: String,
highlights: BTreeMap<String, Style>,
) -> Self {
Theme {
name,
appearance,
revision,
highlights,
}
}
#[deprecated(note = "use themes::CssBuilder instead")]
pub fn css(&self, enable_italic: bool) -> String {
CssBuilder::new(self).enable_italic(enable_italic).build()
}
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);
}
if let Some(style) = RAINBOW_BRACKET_FALLBACKS.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))
}
}
}
#[derive(Builder, Clone, Debug)]
#[builder(
vis = "pub",
custom_constructor,
create_empty = "empty",
build_fn(private, name = "fallible_build")
)]
struct Css<'a> {
#[builder(setter(custom))]
theme: &'a Theme,
#[builder(default = "true")]
enable_italic: bool,
#[builder(default, setter(into))]
scope: String,
#[builder(default = "\".lumis\".to_string()", setter(into))]
container_selector: String,
#[builder(default, setter(custom))]
container_style: Vec<(String, String)>,
}
impl<'a> CssBuilder<'a> {
pub fn new(theme: &'a Theme) -> Self {
CssBuilder {
theme: Some(theme),
..CssBuilder::empty()
}
}
pub fn container_style<K, V>(
&mut self,
declarations: impl IntoIterator<Item = (K, V)>,
) -> &mut Self
where
K: Into<String>,
V: Into<String>,
{
self.container_style = Some(
declarations
.into_iter()
.map(|(property, value)| (property.into(), value.into()))
.collect(),
);
self
}
#[must_use]
pub fn build(&self) -> String {
self.fallible_build()
.expect("CssBuilder::new initializes the required theme field")
.render()
}
}
impl Css<'_> {
fn render(&self) -> String {
let mut rules = Vec::new();
rules.push(format!(
"/* {}\n * revision: {}\n */\n{}",
self.theme.name,
self.theme.revision,
self.scoped_selector(&self.container_selector),
));
let container_style = self.container_style("\n ");
if container_style.is_empty() {
rules.push(" {}\n".to_string());
} else {
rules.push(format!(" {{\n {container_style}\n}}\n"));
}
for (scope, style) in &self.theme.highlights {
if scope == "normal" {
continue;
}
let style_css = style.css(self.enable_italic, "\n ");
if !style_css.is_empty() {
rules.push(format!(
"{}.l-{} {{\n {}\n}}\n",
self.scope_prefix(),
scope.replace('.', "-"),
style_css
))
};
}
rules.join("")
}
fn scope_prefix(&self) -> String {
if self.scope.is_empty() {
String::new()
} else {
format!("{} ", self.scope)
}
}
fn scoped_selector(&self, selector: &str) -> String {
if self.scope.is_empty() {
selector.to_string()
} else {
format!("{} {}", self.scope, selector)
}
}
fn container_style(&self, separator: &str) -> String {
let mut decls: Vec<(String, String)> = Vec::new();
if let Some(fg) = self.theme.fg() {
decls.push(("color".to_string(), fg.to_string()));
}
if let Some(bg) = self.theme.bg() {
decls.push(("background-color".to_string(), bg.to_string()));
}
for (property, value) in &self.container_style {
if let Some(existing) = decls.iter_mut().find(|(p, _)| p == property) {
existing.1 = value.clone();
} else {
decls.push((property.clone(), value.clone()));
}
}
decls
.iter()
.map(|(property, value)| format!("{property}: {value};"))
.collect::<Vec<_>>()
.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("#e5c891".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_css_builder_default_stylesheet() {
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
*/
.lumis {
color: red;
background-color: green;
}
.l-keyword {
color: blue;
font-style: italic;
}
.l-tag-attribute {
background-color: gray;
font-weight: bold;
}
"#;
assert_eq!(
CssBuilder::new(&theme).enable_italic(true).build(),
expected
);
}
#[test]
fn test_css_builder_can_disable_italic() {
let json = r#"{"name": "test", "appearance": "dark", "revision": "3e976b4", "highlights": {"keyword": {"fg": "blue", "italic": true}}}"#;
let theme = from_json(json).unwrap();
let css = CssBuilder::new(&theme).enable_italic(false).build();
assert!(css.contains(".l-keyword {\n color: blue;\n}"));
assert!(!css.contains("font-style: italic;"));
}
#[test]
fn test_css_builder_scopes_selectors_and_container_style() {
let json = r##"{"name": "test", "appearance": "dark", "revision": "3e976b4", "highlights": {"normal": {"fg": "red", "bg": "green"}, "keyword": {"fg": "blue"}}}"##;
let theme = from_json(json).unwrap();
let expected = r#"/* test
* revision: 3e976b4
*/
html[data-theme="dark"] .lumis {
color: red;
background-color: var(--color-grey-900);
border-radius: 0.375rem;
}
html[data-theme="dark"] .l-keyword {
color: blue;
}
"#;
assert_eq!(
CssBuilder::new(&theme)
.scope("html[data-theme=\"dark\"]")
.container_selector(".lumis")
.container_style([
("background-color", "var(--color-grey-900)"),
("border-radius", "0.375rem"),
])
.build(),
expected
);
}
#[test]
fn test_css_builder_emits_italic_by_default() {
let json = r#"{"name": "test", "appearance": "dark", "revision": "abc", "highlights": {"keyword": {"fg": "blue", "italic": true}}}"#;
let theme = from_json(json).unwrap();
let expected = r#"/* test
* revision: abc
*/
.lumis {}
.l-keyword {
color: blue;
font-style: italic;
}
"#;
assert_eq!(CssBuilder::new(&theme).build(), expected);
}
#[test]
fn test_css_builder_empty_theme_renders_empty_base_rule() {
let json = r#"{"name": "test", "appearance": "dark", "revision": "abc", "highlights": {}}"#;
let theme = from_json(json).unwrap();
let expected = r#"/* test
* revision: abc
*/
.lumis {}
"#;
assert_eq!(CssBuilder::new(&theme).build(), expected);
}
#[test]
fn test_css_builder_container_rule_keeps_theme_background() {
let json = r#"{"name": "test", "appearance": "dark", "revision": "abc", "highlights": {"normal": {"fg": "red", "bg": "green"}, "keyword": {"fg": "blue"}}}"#;
let theme = from_json(json).unwrap();
let expected = r#"/* test
* revision: abc
*/
.lumis {
color: red;
background-color: green;
padding: 1rem;
}
.l-keyword {
color: blue;
}
"#;
assert_eq!(
CssBuilder::new(&theme)
.container_style([("padding", "1rem")])
.build(),
expected
);
}
#[test]
fn test_css_builder_container_rule_adds_background_when_theme_lacks_bg() {
let json = r#"{"name": "test", "appearance": "dark", "revision": "abc", "highlights": {"normal": {"fg": "red"}}}"#;
let theme = from_json(json).unwrap();
let expected = r#"/* test
* revision: abc
*/
.lumis {
color: red;
background-color: #000;
}
"#;
assert_eq!(
CssBuilder::new(&theme)
.container_style([("background-color", "#000")])
.build(),
expected
);
}
#[test]
fn test_css_builder_container_rule_overrides_theme_background() {
let json = r#"{"name": "test", "appearance": "dark", "revision": "abc", "highlights": {"normal": {"fg": "red", "bg": "green"}}}"#;
let theme = from_json(json).unwrap();
let expected = r#"/* test
* revision: abc
*/
.lumis {
color: red;
background-color: #000;
}
"#;
let css = CssBuilder::new(&theme)
.container_style([("background-color", "#000")])
.build();
assert_eq!(css, expected);
assert!(!css.contains("green"));
}
#[test]
fn test_css_builder_omits_rules_that_render_no_declarations() {
let json = r#"{"name": "test", "appearance": "dark", "revision": "abc", "highlights": {"function": {"fg": "red"}, "comment": {"italic": true}}}"#;
let theme = from_json(json).unwrap();
let css = CssBuilder::new(&theme).enable_italic(false).build();
assert!(css.contains(".l-function {\n color: red;\n}"));
assert!(!css.contains(".l-comment"));
}
#[test]
fn test_css_builder_container_style_preserves_insertion_order() {
let json = r#"{"name": "test", "appearance": "dark", "revision": "abc", "highlights": {"normal": {"fg": "red"}}}"#;
let theme = from_json(json).unwrap();
let expected = r#"/* test
* revision: abc
*/
.lumis {
color: red;
border-radius: 0.375rem;
padding: 1rem;
overflow-x: auto;
}
"#;
assert_eq!(
CssBuilder::new(&theme)
.container_style([
("border-radius", "0.375rem"),
("padding", "1rem"),
("overflow-x", "auto"),
])
.build(),
expected
);
}
#[test]
fn test_css_builder_scope() {
let json = r#"{"name": "test", "appearance": "dark", "revision": "abc", "highlights": {"normal": {"fg": "red"}, "keyword": {"fg": "blue"}}}"#;
let theme = from_json(json).unwrap();
let expected = r#"/* test
* revision: abc
*/
.app .lumis {
color: red;
}
.app .l-keyword {
color: blue;
}
"#;
assert_eq!(CssBuilder::new(&theme).scope(".app").build(), expected);
}
#[test]
fn test_css_builder_renders_text_decorations() {
let json = r#"{"name": "test", "appearance": "dark", "revision": "abc", "highlights": {"keyword": {"fg": "blue", "bold": true, "italic": true, "underline": "double", "strikethrough": true}}}"#;
let theme = from_json(json).unwrap();
let expected = r#"/* test
* revision: abc
*/
.lumis {}
.l-keyword {
color: blue;
font-weight: bold;
font-style: italic;
text-decoration: underline double line-through;
}
"#;
assert_eq!(CssBuilder::new(&theme).build(), expected);
}
#[test]
fn test_css_builder_nested_scope_dots_become_dashes() {
let json = r#"{"name": "test", "appearance": "dark", "revision": "abc", "highlights": {"markup.heading.1.markdown": {"fg": "red"}}}"#;
let theme = from_json(json).unwrap();
let css = CssBuilder::new(&theme).build();
assert!(css.contains(".l-markup-heading-1-markdown {\n color: red;\n}"));
}
#[test]
fn test_css_builder_build_does_not_consume_and_setters_mutate_in_place() {
let json = r#"{"name": "test", "appearance": "dark", "revision": "abc", "highlights": {"keyword": {"fg": "blue", "italic": true}}}"#;
let theme = from_json(json).unwrap();
let mut builder = CssBuilder::new(&theme);
let first = builder.build();
let second = builder.build();
assert_eq!(first, second, "build(&self) must be repeatable");
assert!(first.contains("font-style: italic;"));
builder.enable_italic(false);
let third = builder.build();
assert!(
!third.contains("font-style: italic;"),
"mutating a setter must be reflected on the next build"
);
}
#[test]
fn test_css_builder_new_sets_required_fields() {
let json = r#"{"name": "test", "appearance": "dark", "revision": "abc", "highlights": {}}"#;
let theme = from_json(json).unwrap();
let _ = CssBuilder::new(&theme).build();
}
#[test]
fn test_css_builder_default_matches_bundled_css_files() {
let css_dir = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("lumis")
.join("css");
for theme in available_themes() {
let css_path = css_dir.join(format!("{}.css", theme.name));
let expected = std::fs::read_to_string(&css_path)
.unwrap_or_else(|e| panic!("missing bundled CSS for {}: {e}", theme.name));
let expected = expected.replace("\r\n", "\n");
assert_eq!(
CssBuilder::new(theme).build(),
expected,
"default CssBuilder output drifted from {}",
css_path.display()
);
}
}
#[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()
})
);
}
}