use crate::formatter::html::{HIGHLIGHTED_LINE_NUMBER_CLASS, LINE_NUMBER_CLASS};
use serde::{Deserialize, Serialize};
use std::{collections::BTreeMap, fs, path::Path, str::FromStr};
use derive_builder::Builder;
mod choice;
pub use available_themes as available;
pub use choice::{closest, TerminalColors};
#[derive(Debug, Clone)]
#[non_exhaustive]
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, PartialEq, Eq, Deserialize, Serialize)]
pub struct Theme {
pub name: String,
pub appearance: Appearance,
pub revision: String,
#[serde(default = "unknown_license")]
pub license: 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::Visitor<'_> 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,
},
})
}
}
fn serialized_style_field_count(style: &Style) -> usize {
usize::from(style.fg.is_some())
+ usize::from(style.bg.is_some())
+ usize::from(style.bold)
+ usize::from(style.italic)
+ usize::from(style.text_decoration.underline != UnderlineStyle::None)
+ usize::from(style.text_decoration.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 state = serializer.serialize_struct("Style", serialized_style_field_count(self))?;
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()
},
),
])
});
const NO_ASSERTION: &str = "NOASSERTION";
fn unknown_license() -> String {
NO_ASSERTION.to_string()
}
impl Default for Theme {
fn default() -> Self {
Theme {
name: String::new(),
appearance: Appearance::default(),
revision: String::new(),
license: unknown_license(),
highlights: BTreeMap::new(),
}
}
}
impl Theme {
#[must_use]
pub fn map_styles(mut self, mut transform: impl FnMut(&str, Style) -> Style) -> Self {
for (scope, style) in &mut self.highlights {
*style = transform(scope, std::mem::take(style));
}
self
}
pub fn new(
name: String,
appearance: Appearance,
revision: String,
highlights: BTreeMap<String, Style>,
) -> Self {
Theme {
name,
appearance,
revision,
highlights,
..Theme::default()
}
}
pub(crate) fn without_italics(self) -> Self {
self.map_styles(|_, mut style| {
style.italic = false;
style
})
}
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")]
layout: bool,
#[builder(default = "true", setter(custom))]
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()
}
}
#[doc(hidden)]
#[deprecated(
since = "4.2.0",
note = "use Theme::map_styles to clear style.italic on the desired scopes before building CSS"
)]
pub fn enable_italic(&mut self, enable_italic: bool) -> &mut Self {
self.enable_italic = Some(enable_italic);
self
}
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()
}
}
const LAYOUT_RULES: [(&str, &[&str]); 3] = [
(
"pre.lumis > code",
&[
"display: block",
"width: fit-content",
"min-width: 100%",
"overflow-wrap: anywhere",
],
),
(
"pre.lumis > code > span.l-line",
&[
"display: inline-block",
"width: 100%",
"min-height: 1lh",
"vertical-align: top",
],
),
(
".l-line-number",
&["-webkit-user-select: none", "user-select: none"],
),
];
impl Css<'_> {
fn render(&self) -> String {
let mut rules = Vec::new();
rules.push(format!(
"/* {}\n * revision: {}\n * license: {}\n */\n{}",
self.theme.name,
self.theme.revision,
self.theme.license,
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 = if self.enable_italic || !style.italic {
style.css("\n ")
} else {
Style {
italic: false,
..style.clone()
}
.css("\n ")
};
if !style_css.is_empty() {
rules.push(format!(
"{}{} {{\n {}\n}}\n",
self.scope_prefix(),
self.selector(scope),
style_css
));
}
}
if self.layout {
rules.push(self.layout());
}
rules.join("")
}
fn layout(&self) -> String {
let rules: Vec<String> = LAYOUT_RULES
.iter()
.map(|(selector, declarations)| {
format!(
" {} {{\n {};\n }}\n",
self.scoped_selector(selector),
declarations.join(";\n ")
)
})
.collect();
format!("@layer lumis {{\n{}}}\n", rules.concat())
}
fn selector(&self, scope: &str) -> String {
match scope {
"line_number"
if self
.theme
.highlights
.contains_key("line_number.highlighted") =>
{
format!(".{LINE_NUMBER_CLASS}:not(.{HIGHLIGHTED_LINE_NUMBER_CLASS})")
}
"line_number" => format!(".{LINE_NUMBER_CLASS}"),
"line_number.highlighted" => format!(".{HIGHLIGHTED_LINE_NUMBER_CLASS}"),
_ => format!(".l-{}", scope.replace('.', "-")),
}
}
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.clone_from(value);
} else {
decls.push((property.clone(), value.clone()));
}
}
decls
.iter()
.map(|(property, value)| format!("{property}: {value};"))
.collect::<Vec<_>>()
.join(separator)
}
}
impl Style {
#[doc(hidden)]
pub fn inherit(&mut self, parent: &Self) {
if self.fg.is_none() {
self.fg.clone_from(&parent.fg);
}
if self.bg.is_none() {
self.bg.clone_from(&parent.bg);
}
self.bold |= parent.bold;
self.italic |= parent.italic;
self.text_decoration.strikethrough |= parent.text_decoration.strikethrough;
if self.text_decoration.underline == UnderlineStyle::None {
self.text_decoration.underline = parent.text_decoration.underline;
}
}
pub fn css(&self, 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 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 map_styles_matches_shared_contract() {
#[derive(Deserialize)]
struct Case {
theme: Theme,
expected: Theme,
}
let case: Case =
serde_json::from_str(include_str!("../../../fixtures/theme-map-styles.json")).unwrap();
assert_eq!(case.theme.highlights.len(), 6);
let mut visited = BTreeMap::new();
let mapped = case.theme.clone().map_styles(|scope, mut style| {
*visited.entry(scope.to_owned()).or_insert(0) += 1;
match scope {
"markup.raw.block" => style.italic = false,
"normal" => style.bg = Some("#ffffff".into()),
"highlighted" => return Style::default(),
_ => {}
}
style
});
assert_eq!(mapped, case.expected);
assert_eq!(visited.len(), 6);
assert!(visited.values().all(|count| *count == 1));
assert!(case.theme.highlights["markup.raw.block"].italic);
assert!(CssBuilder::new(&mapped)
.build()
.contains("text-decoration: line-through;"));
assert_eq!(
CssBuilder::new(&mapped).build(),
CssBuilder::new(&case.expected).build()
);
}
#[test]
fn map_styles_composes_and_handles_empty_themes() {
let theme = get("github_light").unwrap().map_styles(|_, mut style| {
style.italic = false;
style
});
assert!(!CssBuilder::new(&theme)
.build()
.contains("font-style: italic"));
let theme = theme.map_styles(|scope, mut style| {
style.italic = scope == "markup.italic";
style
});
assert_eq!(
CssBuilder::new(&theme)
.build()
.matches("font-style: italic")
.count(),
1
);
assert_eq!(
Theme::default().map_styles(|_, _| panic!("empty theme has no styles")),
Theme::default()
);
}
#[test]
fn test_available_themes() {
let themes: Vec<_> = available_themes().collect();
assert!(!themes.is_empty(), "no themes are compiled in");
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(), "a theme has no name");
assert!(theme.appearance == Appearance::Light || theme.appearance == Appearance::Dark);
}
}
#[test]
fn available_themes_is_sorted_by_name() {
let names: Vec<&str> = available_themes()
.map(|theme| theme.name.as_str())
.collect();
assert!(
names.len() > 200,
"expected the full theme corpus, got {}",
names.len()
);
let mut sorted = names.clone();
sorted.sort_unstable();
assert_eq!(
names, sorted,
"available_themes() must not depend on filesystem order"
);
}
#[test]
fn test_load_all_themes() {
for theme in ALL_THEMES.iter() {
assert!(!theme.name.is_empty(), "a theme has no name");
}
let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
let expected_count = std::fs::read_dir(manifest_dir.join("themes"))
.unwrap()
.filter_map(std::result::Result::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 license_is_read_from_json_and_unknown_when_absent() {
let with = r#"{"name": "t", "appearance": "dark", "revision": "abc", "license": "MIT", "highlights": {}}"#;
let without = r#"{"name": "t", "appearance": "dark", "revision": "abc", "highlights": {}}"#;
let theme = from_json(with).unwrap();
assert_eq!(theme.license, "MIT");
assert!(CssBuilder::new(&theme)
.build()
.starts_with("/* t\n * revision: abc\n * license: MIT\n */\n"));
assert_eq!(from_json(without).unwrap().license, "NOASSERTION");
assert_eq!(Theme::default().license, "NOASSERTION");
assert_eq!(get("dracula").unwrap().license, "MIT");
}
#[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!(theme.get_style("tag.attribute").is_some());
}
#[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(" "),
"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}, "line_number": {"fg": "silver"}, "line_number.highlighted": {"fg": "white", "bold": true}, "tag.attribute": {"bg": "gray", "bold": true}}}"#;
let theme = from_json(json).unwrap();
let expected = r"/* test
* revision: 3e976b4
* license: NOASSERTION
*/
.lumis {
color: red;
background-color: green;
}
.l-keyword {
color: blue;
font-style: italic;
}
.l-line-number:not(.l-line-number-highlighted) {
color: silver;
}
.l-line-number-highlighted {
color: white;
font-weight: bold;
}
.l-tag-attribute {
background-color: gray;
font-weight: bold;
}
@layer lumis {
pre.lumis > code {
display: block;
width: fit-content;
min-width: 100%;
overflow-wrap: anywhere;
}
pre.lumis > code > span.l-line {
display: inline-block;
width: 100%;
min-height: 1lh;
vertical-align: top;
}
.l-line-number {
-webkit-user-select: none;
user-select: none;
}
}
";
assert_eq!(CssBuilder::new(&theme).build(), expected);
}
#[test]
fn test_css_builder_line_number_falls_back_when_highlighted_style_is_absent() {
let json = r#"{"name": "test", "appearance": "dark", "revision": "abc", "highlights": {"line_number": {"fg": "silver"}}}"#;
let theme = from_json(json).unwrap();
let css = CssBuilder::new(&theme).build();
assert!(css.contains(".l-line-number {\n color: silver;\n}"));
assert!(!css.contains(".l-line-number:not("));
}
#[test]
fn test_css_builder_writes_the_classes_the_formatters_write() {
let highlights = crate::highlights::HIGHLIGHT_NAMES
.iter()
.map(|scope| {
(
(*scope).to_string(),
Style {
fg: Some("red".to_string()),
..Default::default()
},
)
})
.collect();
let theme = Theme {
name: "test".to_string(),
highlights,
..Default::default()
};
let css = CssBuilder::new(&theme).build();
let underscored = crate::highlights::HIGHLIGHT_NAMES
.iter()
.filter(|scope| scope.contains('_'))
.count();
assert!(
underscored >= 9,
"the scopes this pins are gone: {underscored} left"
);
for scope in crate::highlights::HIGHLIGHT_NAMES {
if scope == "normal" {
continue;
}
let class = crate::formatter::html::scope_to_class(scope);
assert!(
css.contains(&format!("\n.{class} {{\n")),
"{scope} has no rule for {class}, the class the formatters write"
);
}
}
#[test]
fn every_bundled_theme_has_a_visible_diff_word_background() {
use crate::decorations::DiffWordKind;
let themes: Vec<_> = available_themes().collect();
assert!(themes.len() > 200, "found only {} themes", themes.len());
for theme in themes {
for kind in [DiffWordKind::Removed, DiffWordKind::Added] {
let scope = kind.scope();
let word = theme
.highlights
.get(scope)
.and_then(|style| style.bg.as_deref())
.unwrap_or_else(|| panic!("{}: no {scope} background", theme.name));
let line_scope = scope.trim_end_matches(".word");
let line = theme.highlights[line_scope]
.bg
.as_deref()
.or_else(|| theme.bg());
assert_ne!(
Some(word),
line,
"{}: {scope} is the color of {line_scope}",
theme.name
);
}
}
}
#[test]
#[allow(deprecated, reason = "verify legacy italic suppression")]
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
* license: NOASSERTION
*/
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;
}
@layer lumis {
html[data-theme="dark"] pre.lumis > code {
display: block;
width: fit-content;
min-width: 100%;
overflow-wrap: anywhere;
}
html[data-theme="dark"] pre.lumis > code > span.l-line {
display: inline-block;
width: 100%;
min-height: 1lh;
vertical-align: top;
}
html[data-theme="dark"] .l-line-number {
-webkit-user-select: none;
user-select: none;
}
}
"#;
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
* license: NOASSERTION
*/
.lumis {}
.l-keyword {
color: blue;
font-style: italic;
}
@layer lumis {
pre.lumis > code {
display: block;
width: fit-content;
min-width: 100%;
overflow-wrap: anywhere;
}
pre.lumis > code > span.l-line {
display: inline-block;
width: 100%;
min-height: 1lh;
vertical-align: top;
}
.l-line-number {
-webkit-user-select: none;
user-select: none;
}
}
";
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
* license: NOASSERTION
*/
.lumis {}
@layer lumis {
pre.lumis > code {
display: block;
width: fit-content;
min-width: 100%;
overflow-wrap: anywhere;
}
pre.lumis > code > span.l-line {
display: inline-block;
width: 100%;
min-height: 1lh;
vertical-align: top;
}
.l-line-number {
-webkit-user-select: none;
user-select: none;
}
}
";
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
* license: NOASSERTION
*/
.lumis {
color: red;
background-color: green;
padding: 1rem;
}
.l-keyword {
color: blue;
}
@layer lumis {
pre.lumis > code {
display: block;
width: fit-content;
min-width: 100%;
overflow-wrap: anywhere;
}
pre.lumis > code > span.l-line {
display: inline-block;
width: 100%;
min-height: 1lh;
vertical-align: top;
}
.l-line-number {
-webkit-user-select: none;
user-select: none;
}
}
";
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
* license: NOASSERTION
*/
.lumis {
color: red;
background-color: #000;
}
@layer lumis {
pre.lumis > code {
display: block;
width: fit-content;
min-width: 100%;
overflow-wrap: anywhere;
}
pre.lumis > code > span.l-line {
display: inline-block;
width: 100%;
min-height: 1lh;
vertical-align: top;
}
.l-line-number {
-webkit-user-select: none;
user-select: none;
}
}
";
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
* license: NOASSERTION
*/
.lumis {
color: red;
background-color: #000;
}
@layer lumis {
pre.lumis > code {
display: block;
width: fit-content;
min-width: 100%;
overflow-wrap: anywhere;
}
pre.lumis > code > span.l-line {
display: inline-block;
width: 100%;
min-height: 1lh;
vertical-align: top;
}
.l-line-number {
-webkit-user-select: none;
user-select: none;
}
}
";
let css = CssBuilder::new(&theme)
.container_style([("background-color", "#000")])
.build();
assert_eq!(css, expected);
assert!(!css.contains("green"));
}
#[test]
#[allow(deprecated, reason = "verify legacy italic suppression")]
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
* license: NOASSERTION
*/
.lumis {
color: red;
border-radius: 0.375rem;
padding: 1rem;
overflow-x: auto;
}
@layer lumis {
pre.lumis > code {
display: block;
width: fit-content;
min-width: 100%;
overflow-wrap: anywhere;
}
pre.lumis > code > span.l-line {
display: inline-block;
width: 100%;
min-height: 1lh;
vertical-align: top;
}
.l-line-number {
-webkit-user-select: none;
user-select: none;
}
}
";
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
* license: NOASSERTION
*/
.app .lumis {
color: red;
}
.app .l-keyword {
color: blue;
}
@layer lumis {
.app pre.lumis > code {
display: block;
width: fit-content;
min-width: 100%;
overflow-wrap: anywhere;
}
.app pre.lumis > code > span.l-line {
display: inline-block;
width: 100%;
min-height: 1lh;
vertical-align: top;
}
.app .l-line-number {
-webkit-user-select: none;
user-select: none;
}
}
";
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
* license: NOASSERTION
*/
.lumis {}
.l-keyword {
color: blue;
font-weight: bold;
font-style: italic;
text-decoration: underline double line-through;
}
@layer lumis {
pre.lumis > code {
display: block;
width: fit-content;
min-width: 100%;
overflow-wrap: anywhere;
}
pre.lumis > code > span.l-line {
display: inline-block;
width: 100%;
min-height: 1lh;
vertical-align: top;
}
.l-line-number {
-webkit-user-select: none;
user-select: none;
}
}
";
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]
#[allow(deprecated, reason = "verify legacy builder mutation")]
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()
})
);
}
}