use crate::embedded;
use std::io::ErrorKind;
use std::path::Path;
#[derive(Debug)]
pub struct Theme {
pub name: &'static str,
pub sheet_path: &'static str,
pub embedded: &'static [u8],
}
pub static THEMES: [Theme; 6] = [
Theme {
name: DEFAULT_THEME_NAME,
sheet_path: "css/error.css",
embedded: embedded::DEFAULT_SHEET,
},
Theme {
name: "minimal",
sheet_path: "themes/minimal.css",
embedded: include_bytes!("../content/themes/minimal.css"),
},
Theme {
name: "terminal",
sheet_path: "themes/terminal.css",
embedded: include_bytes!("../content/themes/terminal.css"),
},
Theme {
name: "paper",
sheet_path: "themes/paper.css",
embedded: include_bytes!("../content/themes/paper.css"),
},
Theme {
name: "amber",
sheet_path: "themes/amber.css",
embedded: include_bytes!("../content/themes/amber.css"),
},
Theme {
name: "compact",
sheet_path: "themes/compact.css",
embedded: include_bytes!("../content/themes/compact.css"),
},
];
pub const DEFAULT_THEME_NAME: &str = "default";
pub const THEME_NAMES: &str = "default, minimal, terminal, paper, amber, compact";
pub fn theme_by_name(name: &str) -> Option<&'static Theme> {
THEMES.iter().find(|theme| theme.name == name)
}
pub fn default_theme() -> &'static Theme {
&THEMES[0]
}
pub fn resolve(requested: Option<&str>, configured: &'static Theme) -> &'static Theme {
requested.and_then(theme_by_name).unwrap_or(configured)
}
impl Theme {
pub fn sheet(&self, content_dir: &Path) -> Vec<u8> {
let path = content_dir.join(self.sheet_path);
match std::fs::read(&path) {
Ok(bytes) => bytes,
Err(err) if err.kind() == ErrorKind::NotFound => self.embedded.to_vec(),
Err(err) => {
tracing::warn!(
theme = self.name,
path = %path.display(),
error = %err,
"no se pudo leer la hoja del tema: se usa la copia embebida"
);
self.embedded.to_vec()
}
}
}
}
#[cfg(test)]
mod tests {
use super::{DEFAULT_THEME_NAME, THEME_NAMES, THEMES, default_theme, resolve, theme_by_name};
use crate::embedded;
use std::path::PathBuf;
fn content_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("content")
}
const NOMBRES: [&str; 6] = [
"default", "minimal", "terminal", "paper", "amber", "compact",
];
fn es_de_identificador(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_'
}
fn es_espacio(byte: u8) -> bool {
matches!(byte, b' ' | b'\t' | b'\n' | b'\r')
}
fn sin_comentarios(css: &str) -> String {
let mut salida = String::with_capacity(css.len());
let mut resto = css;
while let Some(inicio) = resto.find("/*") {
salida.push_str(&resto[..inicio]);
match resto[inicio + 2..].find("*/") {
Some(fin) => resto = &resto[inicio + 2 + fin + 2..],
None => return salida,
}
}
salida.push_str(resto);
salida
}
fn tiene_regla(css: &str, selector: &str) -> bool {
let css = sin_comentarios(css);
let bytes = css.as_bytes();
for (inicio, _) in css.match_indices(selector) {
if inicio > 0 && es_de_identificador(bytes[inicio - 1]) {
continue;
}
let mut cursor = inicio + selector.len();
if bytes.get(cursor).copied().is_some_and(es_de_identificador) {
continue;
}
while bytes.get(cursor).copied().is_some_and(es_espacio) {
cursor += 1;
}
if matches!(bytes.get(cursor), Some(b'{') | Some(b',')) {
return true;
}
}
false
}
#[test]
fn las_hojas_embebidas_son_byte_a_byte_las_de_content() {
for theme in THEMES.iter() {
let ruta = content_dir().join(theme.sheet_path);
let del_disco = match std::fs::read(&ruta) {
Ok(bytes) => bytes,
Err(error) => panic!(
"no se pudo leer {} (tema `{}`): {error}",
ruta.display(),
theme.name
),
};
assert_eq!(
theme.embedded,
del_disco.as_slice(),
"la hoja embebida del tema `{}` no coincide byte a byte con {}",
theme.name,
ruta.display()
);
}
}
#[test]
fn la_tabla_declara_los_seis_temas() {
assert_eq!(
THEMES.len(),
NOMBRES.len(),
"`THEMES` tiene que declarar los seis temas incorporados"
);
assert_eq!(
THEMES[0].name, DEFAULT_THEME_NAME,
"`THEMES[0]` tiene que ser el tema por defecto"
);
for (theme, nombre) in THEMES.iter().zip(NOMBRES) {
assert_eq!(
theme.name, nombre,
"el orden de `THEMES` tiene que ser el de la lista de nombres"
);
}
let enumerados: Vec<&str> = THEME_NAMES.split(',').map(str::trim).collect();
assert_eq!(
enumerados, NOMBRES,
"`THEME_NAMES` tiene que enumerar los seis nombres: {THEME_NAMES}"
);
}
#[test]
fn theme_by_name_solo_acepta_los_seis() {
for nombre in NOMBRES {
assert!(
theme_by_name(nombre).is_some(),
"`theme_by_name` tiene que encontrar el tema `{nombre}`"
);
}
for desconocido in [
"",
"neon",
"Default",
"TERMINAL",
"Themes/default",
"default ",
] {
assert!(
theme_by_name(desconocido).is_none(),
"`theme_by_name({desconocido:?})` tiene que ser `None`: la comparación es exacta"
);
}
}
#[test]
fn resolve_el_conocido_gana_y_lo_demas_cae_al_configurado() {
let configurado = theme_by_name("minimal").expect("`minimal` es uno de los seis");
assert_eq!(
resolve(Some("terminal"), configurado).name,
"terminal",
"un `X-Theme` conocido tiene que ganar al tema configurado"
);
assert_eq!(
resolve(Some("neon"), configurado).name,
"minimal",
"un `X-Theme` desconocido cae al configurado, no a otro tema"
);
assert_eq!(
resolve(Some(""), configurado).name,
"minimal",
"un `X-Theme` vacío no es un tema: cae al configurado"
);
assert_eq!(
resolve(None, configurado).name,
"minimal",
"sin `X-Theme` el tema es el configurado"
);
assert_eq!(
resolve(None, default_theme()).name,
DEFAULT_THEME_NAME,
"sin `X-Theme` y con el defecto configurado, el tema es `default`"
);
}
#[test]
fn cada_hoja_cubre_el_marcado() {
let mut fallos = Vec::new();
for theme in THEMES.iter() {
let hoja = String::from_utf8_lossy(theme.embedded);
for selector in ["body", "h1", "h2", "hr", "p"] {
if !tiene_regla(&hoja, selector) {
fallos.push(format!(
"la hoja del tema `{}` no define `{selector}`",
theme.name
));
}
}
}
assert!(
fallos.is_empty(),
"cada hoja de tema tiene que cubrir el marcado ({} incumplimiento(s)):\n- {}",
fallos.len(),
fallos.join("\n- ")
);
}
#[test]
fn ninguna_hoja_referencia_recursos_externos() {
const PROHIBIDOS: [&str; 6] = [
"@import",
"url(",
"@font-face",
"animation",
"transition",
"keyframes",
];
let mut fallos = Vec::new();
for theme in THEMES.iter() {
let hoja = String::from_utf8_lossy(theme.embedded);
for prohibido in PROHIBIDOS {
if hoja.contains(prohibido) {
fallos.push(format!(
"la hoja del tema `{}` contiene `{prohibido}`",
theme.name
));
}
}
}
assert!(
fallos.is_empty(),
"ninguna hoja puede referenciar recursos externos ({} incumplimiento(s)):\n- {}",
fallos.len(),
fallos.join("\n- ")
);
}
#[test]
fn ninguna_hoja_puede_cerrar_el_comentario_ni_el_style() {
const PROHIBIDOS: [&str; 2] = ["-->", "</style>"];
let mut fallos = Vec::new();
for theme in THEMES.iter() {
let hoja = String::from_utf8_lossy(theme.embedded);
for prohibido in PROHIBIDOS {
if hoja.contains(prohibido) {
fallos.push(format!(
"la hoja del tema `{}` contiene `{prohibido}`",
theme.name
));
}
}
}
assert!(
fallos.is_empty(),
"ninguna hoja puede contener `-->` ni `</style>` ({} incumplimiento(s)):\n- {}",
fallos.len(),
fallos.join("\n- ")
);
}
#[test]
fn la_hoja_default_es_la_del_asset() {
let (bytes, content_type) = embedded::asset("/css/error.css")
.expect("la hoja `/css/error.css` tiene que estar en la tabla de assets");
assert_eq!(
content_type, "text/css",
"Content-Type del asset de la hoja"
);
let default = default_theme();
assert_eq!(
default.sheet_path, "css/error.css",
"la hoja del tema `default` es el asset público"
);
assert!(
std::ptr::eq(default.embedded, bytes),
"el tema `default` y el asset tienen que **compartir la hoja** —el mismo puntero, no \
solo los mismos bytes—, pero `THEMES[0].embedded` y el asset apuntan a direcciones \
distintas"
);
}
}