use std::collections::{BTreeMap, HashSet};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use crate::theme::Theme;
const BUNDLED: &[(&str, &str)] = &[
("default", include_str!("../assets/themes/default.toml")),
(
"default-light",
include_str!("../assets/themes/default-light.toml"),
),
(
"high-contrast",
include_str!("../assets/themes/high-contrast.toml"),
),
("ansi", include_str!("../assets/themes/ansi.toml")),
("nord", include_str!("../assets/themes/nord.toml")),
("gruvbox", include_str!("../assets/themes/gruvbox.toml")),
("dracula", include_str!("../assets/themes/dracula.toml")),
(
"catppuccin-mocha",
include_str!("../assets/themes/catppuccin-mocha.toml"),
),
(
"tokyo-night",
include_str!("../assets/themes/tokyo-night.toml"),
),
(
"solarized-dark",
include_str!("../assets/themes/solarized-dark.toml"),
),
];
const MAX_DEPTH: usize = 16;
pub fn bundled_names() -> Vec<&'static str> {
BUNDLED.iter().map(|(name, _)| *name).collect()
}
pub fn user_dir(config_path: &Path) -> Option<PathBuf> {
config_path.parent().map(|dir| dir.join("themes"))
}
pub fn resolve(name: &str, themes_dir: Option<&Path>) -> Result<Theme> {
let mut chain: Vec<toml::Table> = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
let mut next = Some(name.to_string());
while let Some(current) = next.take() {
if !seen.insert(current.clone()) {
bail!(
"theme `{current}` inherits from itself, directly or through \
another theme. Break the loop by removing one `inherits`."
);
}
if chain.len() >= MAX_DEPTH {
bail!("theme `{name}` inherits through more than {MAX_DEPTH} files");
}
let mut table = read(¤t, themes_dir)?;
check_palette_ordering(¤t, &table)?;
if let Some(parent) = table.remove("inherits") {
let toml::Value::String(parent) = parent else {
bail!("`inherits` in theme `{current}` must be a theme name in quotes");
};
next = Some(parent);
}
chain.push(table);
}
chain.reverse();
let mut palette: BTreeMap<String, toml::Value> = BTreeMap::new();
let mut merged = toml::Table::new();
for mut table in chain {
if let Some(toml::Value::Table(entries)) = table.remove("palette") {
palette.extend(entries);
}
merged.extend(table);
}
substitute(&mut merged, &palette);
Theme::deserialize_table(merged)
.with_context(|| format!("in theme `{name}`"))
.map(|theme| theme.named(name))
}
fn is_plain_name(name: &str) -> bool {
!name.is_empty()
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
}
pub fn is_listable(name: &str) -> bool {
is_plain_name(name)
}
fn read(name: &str, themes_dir: Option<&Path>) -> Result<toml::Table> {
if !is_plain_name(name) {
bail!(
"`{name}` is not a theme name. Use letters, digits, dashes and \
underscores — a theme is looked up by name in your themes \
directory, not by path."
);
}
if let Some(path) = themes_dir.map(|dir| dir.join(format!("{name}.toml")))
&& path.exists()
{
let raw = std::fs::read_to_string(&path)
.with_context(|| format!("reading theme {}", path.display()))?;
return toml::from_str(&raw).with_context(|| format!("parsing theme {}", path.display()));
}
if let Some((_, raw)) = BUNDLED.iter().find(|(bundled, _)| *bundled == name) {
return toml::from_str(raw).with_context(|| format!("parsing the bundled `{name}` theme"));
}
let mut known = bundled_names();
known.sort_unstable();
let where_to_put_it = themes_dir.map_or_else(
|| "your themes directory".to_string(),
|dir| dir.display().to_string(),
);
bail!(
"no theme called `{name}`. Built in: {}. For anything else, put \
`{name}.toml` in {where_to_put_it}.",
known.join(", ")
)
}
fn check_palette_ordering(name: &str, table: &toml::Table) -> Result<()> {
let Some(toml::Value::Table(palette)) = table.get("palette") else {
return Ok(());
};
let mut stray: Vec<&str> = palette
.keys()
.map(String::as_str)
.filter(|key| Theme::KEYS.contains(key))
.collect();
if stray.is_empty() {
return Ok(());
}
stray.sort_unstable();
bail!(
"theme `{name}` has {} inside `[palette]`, where {} nothing. In TOML \
every key after a table header belongs to that table, so the colour \
keys have to come *before* `[palette]`, not after it.",
stray.join(", "),
if stray.len() == 1 {
"it sets"
} else {
"they set"
}
)
}
fn substitute(table: &mut toml::Table, palette: &BTreeMap<String, toml::Value>) {
for (_, value) in table.iter_mut() {
let replacement = match value {
toml::Value::String(text) => palette.get(text.as_str()).cloned(),
toml::Value::Table(nested) => {
substitute(nested, palette);
None
}
_ => None,
};
if let Some(resolved) = replacement {
*value = resolved;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
struct TempDir(PathBuf);
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn themes_dir(name: &str) -> (PathBuf, TempDir) {
let dir =
std::env::temp_dir().join(format!("mirador-themes-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("test directory");
(dir.clone(), TempDir(dir))
}
fn write(dir: &Path, name: &str, body: &str) {
std::fs::write(dir.join(format!("{name}.toml")), body).expect("writing a theme");
}
#[test]
fn a_theme_name_cannot_reach_outside_the_themes_directory() {
let (dir, _g) = themes_dir("traversal");
let outside = dir.parent().expect("has a parent").join("outside.toml");
std::fs::write(&outside, "accent = \"#010203\"\n").expect("write");
for name in ["../outside", "/etc/passwd", "sub/theme", "..", ""] {
let err = resolve(name, Some(&dir))
.err()
.unwrap_or_else(|| panic!("`{name}` was accepted as a theme name"));
let message = format!("{err:#}");
assert!(
message.contains("not a theme name") || message.contains("no theme called"),
"`{name}` refused for the wrong reason: {message}"
);
}
write(&dir, "nord", "accent = \"#88c0d0\"\n");
assert!(resolve("nord", Some(&dir)).is_ok());
let _ = std::fs::remove_file(&outside);
}
#[test]
fn a_deeply_nested_theme_is_refused_before_it_can_be_walked() {
let (dir, _g) = themes_dir("deep");
let depth = 50_000;
let mut deep = String::from("accent = \"#010203\"\n\nnest = ");
deep.push_str(&"{ a = ".repeat(depth));
deep.push('1');
deep.push_str(&" }".repeat(depth));
deep.push('\n');
write(&dir, "deep", &deep);
assert!(
resolve("deep", Some(&dir)).is_err(),
"{depth} levels of nesting were walked rather than refused"
);
}
#[test]
fn every_bundled_theme_resolves() {
for name in bundled_names() {
resolve(name, None).unwrap_or_else(|e| panic!("bundled `{name}` failed: {e:#}"));
}
}
#[test]
fn the_bundled_default_theme_matches_the_rust_default() {
let from_file = resolve("default", None).expect("resolves");
let built_in = Theme::default();
assert_eq!(
from_file.colours(),
built_in.colours(),
"assets/themes/default.toml has drifted from Theme::default()"
);
}
#[test]
fn a_standalone_theme_sets_every_key_rather_than_leaning_on_defaults() {
for name in ["default", "default-light", "ansi"] {
let (_, raw) = BUNDLED
.iter()
.find(|(bundled, _)| *bundled == name)
.expect("bundled");
let table: toml::Table = toml::from_str(raw).expect("parses");
let missing: Vec<&str> = Theme::KEYS
.iter()
.copied()
.filter(|key| !table.contains_key(*key))
.collect();
assert!(
missing.is_empty(),
"`{name}.toml` never sets: {}",
missing.join(", ")
);
}
}
#[test]
fn colour_keys_that_fell_inside_the_palette_are_reported() {
let (dir, _g) = themes_dir("ordering");
write(
&dir,
"t",
"[palette]\nbrass = \"#d7af87\"\naccent = \"brass\"\nborder = \"brass\"\n",
);
let err = format!("{:#}", resolve("t", Some(&dir)).expect_err("must fail"));
assert!(err.contains("accent"), "names the key: {err}");
assert!(err.contains("border"), "and every key: {err}");
assert!(err.contains("before"), "and says what to do: {err}");
}
#[test]
fn an_ordinary_palette_is_left_alone() {
let (dir, _g) = themes_dir("ordinary");
write(
&dir,
"t",
"accent = \"brass\"\n\n[palette]\nbrass = \"#010203\"\n",
);
resolve("t", Some(&dir)).expect("a well-ordered theme resolves");
}
#[test]
fn a_theme_on_disk_wins_over_the_bundled_one_of_the_same_name() {
let (dir, _g) = themes_dir("override");
write(&dir, "default", "accent = \"#010203\"\n");
let theme = resolve("default", Some(&dir)).expect("resolves");
assert_eq!(theme.accent, ratatui::style::Color::Rgb(1, 2, 3));
}
#[test]
fn inherits_starts_from_the_parent_and_the_child_overrides_it() {
let (dir, _g) = themes_dir("inherits");
write(
&dir,
"child",
"inherits = \"default\"\naccent = \"#010203\"\n",
);
let child = resolve("child", Some(&dir)).expect("resolves");
let parent = Theme::default();
assert_eq!(
child.accent,
ratatui::style::Color::Rgb(1, 2, 3),
"overridden"
);
assert_eq!(child.border, parent.border, "and the rest is inherited");
}
#[test]
fn a_cycle_is_named_rather_than_run_until_it_gives_up() {
let (dir, _g) = themes_dir("cycle");
write(&dir, "a", "inherits = \"b\"\n");
write(&dir, "b", "inherits = \"a\"\n");
let err = format!("{:#}", resolve("a", Some(&dir)).expect_err("must fail"));
assert!(err.contains("inherits from itself"), "got: {err}");
}
#[test]
fn a_redefined_palette_colour_reaches_the_keys_the_parent_set_with_it() {
let (dir, _g) = themes_dir("palette");
write(
&dir,
"base",
"accent = \"brass\"\n\n[palette]\nbrass = \"#d7af87\"\n",
);
write(
&dir,
"child",
"inherits = \"base\"\n\n[palette]\nbrass = \"#010203\"\n",
);
let theme = resolve("child", Some(&dir)).expect("resolves");
assert_eq!(
theme.accent,
ratatui::style::Color::Rgb(1, 2, 3),
"the parent's `accent = brass` follows the child's redefinition"
);
}
#[test]
fn an_unknown_name_lists_what_is_available() {
let err = format!(
"{:#}",
resolve("no-such-theme", None).expect_err("must fail")
);
assert!(
err.contains("no theme called `no-such-theme`"),
"got: {err}"
);
for name in bundled_names() {
assert!(err.contains(name), "`{name}` missing from: {err}");
}
}
#[test]
fn a_misspelled_palette_name_is_reported_as_a_bad_colour() {
let (dir, _g) = themes_dir("typo");
write(
&dir,
"t",
"accent = \"brss\"\n\n[palette]\nbrass = \"#d7af87\"\n",
);
let err = format!("{:#}", resolve("t", Some(&dir)).expect_err("must fail"));
assert!(err.contains("brss"), "the message names the typo: {err}");
}
#[test]
fn a_misspelled_key_in_a_theme_file_is_refused() {
let (dir, _g) = themes_dir("badkey");
write(&dir, "t", "acent = \"#010203\"\n");
let err = format!("{:#}", resolve("t", Some(&dir)).expect_err("must fail"));
assert!(err.contains("acent"), "the message names the key: {err}");
}
}