use crate::color::{parse_color, ColorError};
use crate::variables::{VariableError, VariableRegistry};
use image::Rgba;
use std::collections::HashMap;
use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum PaletteParseError {
#[error("variable error for '{token}': {error}")]
VariableError { token: String, error: VariableError },
#[error("color error for '{token}' (value '{value}'): {error}")]
ColorError { token: String, value: String, error: ColorError },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PaletteParseWarning {
pub token: String,
pub message: String,
}
impl PaletteParseWarning {
pub fn new(token: impl Into<String>, message: impl Into<String>) -> Self {
Self { token: token.into(), message: message.into() }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ParseMode {
Strict,
#[default]
Lenient,
}
pub const MAGENTA: Rgba<u8> = Rgba([255, 0, 255, 255]);
#[derive(Debug, Clone)]
pub struct ParsedPalette {
pub colors: HashMap<String, Rgba<u8>>,
pub variables: VariableRegistry,
pub warnings: Vec<PaletteParseWarning>,
}
impl ParsedPalette {
pub fn new() -> Self {
Self { colors: HashMap::new(), variables: VariableRegistry::new(), warnings: Vec::new() }
}
}
impl Default for ParsedPalette {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Default)]
pub struct PaletteParser {
external_vars: Option<VariableRegistry>,
}
impl PaletteParser {
pub fn new() -> Self {
Self { external_vars: None }
}
pub fn with_external_vars(external: VariableRegistry) -> Self {
Self { external_vars: Some(external) }
}
pub fn parse(
&self,
raw: &HashMap<String, String>,
mode: ParseMode,
) -> Result<ParsedPalette, PaletteParseError> {
let mut result = ParsedPalette::new();
self.collect_variables(raw, &mut result.variables);
if let Some(external) = &self.external_vars {
for (name, value) in external.iter() {
if !result.variables.contains(name) {
result.variables.define(name, value);
}
}
}
for (token, value) in raw {
if token.starts_with("--") {
continue;
}
match self.resolve_color(token, value, &result.variables) {
Ok(color) => {
result.colors.insert(token.clone(), color);
}
Err(e) => {
if mode == ParseMode::Strict {
return Err(e);
}
result.warnings.push(PaletteParseWarning::new(token.clone(), e.to_string()));
result.colors.insert(token.clone(), MAGENTA);
}
}
}
Ok(result)
}
fn collect_variables(&self, raw: &HashMap<String, String>, registry: &mut VariableRegistry) {
for (key, value) in raw {
if key.starts_with("--") {
registry.define(key, value);
}
}
}
fn resolve_color(
&self,
token: &str,
value: &str,
registry: &VariableRegistry,
) -> Result<Rgba<u8>, PaletteParseError> {
let resolved = if value.contains("var(") {
registry.resolve(value).map_err(|e| PaletteParseError::VariableError {
token: token.to_string(),
error: e,
})?
} else {
value.to_string()
};
parse_color(&resolved).map_err(|e| PaletteParseError::ColorError {
token: token.to_string(),
value: resolved,
error: e,
})
}
pub fn resolve_to_strings(
&self,
raw: &HashMap<String, String>,
mode: ParseMode,
) -> Result<ResolvedPaletteStrings, PaletteParseError> {
let mut result = ResolvedPaletteStrings {
colors: HashMap::new(),
variables: VariableRegistry::new(),
warnings: Vec::new(),
};
self.collect_variables(raw, &mut result.variables);
if let Some(external) = &self.external_vars {
for (name, value) in external.iter() {
if !result.variables.contains(name) {
result.variables.define(name, value);
}
}
}
for (token, value) in raw {
if token.starts_with("--") {
continue;
}
let resolved = if value.contains("var(") {
match result.variables.resolve(value) {
Ok(r) => r,
Err(e) => {
if mode == ParseMode::Strict {
return Err(PaletteParseError::VariableError {
token: token.clone(),
error: e,
});
}
result
.warnings
.push(PaletteParseWarning::new(token.clone(), e.to_string()));
value.clone()
}
}
} else {
value.clone()
};
result.colors.insert(token.clone(), resolved);
}
Ok(result)
}
}
#[derive(Debug, Clone)]
pub struct ResolvedPaletteStrings {
pub colors: HashMap<String, String>,
pub variables: VariableRegistry,
pub warnings: Vec<PaletteParseWarning>,
}
#[cfg(test)]
mod tests {
use super::*;
fn make_palette(entries: &[(&str, &str)]) -> HashMap<String, String> {
entries.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
}
#[test]
fn test_parse_simple_colors() {
let raw = make_palette(&[("{r}", "#FF0000"), ("{g}", "#00FF00"), ("{b}", "#0000FF")]);
let parser = PaletteParser::new();
let result = parser.parse(&raw, ParseMode::Lenient).unwrap();
assert_eq!(result.colors.get("{r}"), Some(&Rgba([255, 0, 0, 255])));
assert_eq!(result.colors.get("{g}"), Some(&Rgba([0, 255, 0, 255])));
assert_eq!(result.colors.get("{b}"), Some(&Rgba([0, 0, 255, 255])));
assert!(result.warnings.is_empty());
}
#[test]
fn test_parse_css_color_formats() {
let raw = make_palette(&[
("{hex}", "#FF0000"),
("{rgb}", "rgb(0, 255, 0)"),
("{hsl}", "hsl(240, 100%, 50%)"),
("{named}", "coral"),
]);
let parser = PaletteParser::new();
let result = parser.parse(&raw, ParseMode::Lenient).unwrap();
assert_eq!(result.colors.get("{hex}"), Some(&Rgba([255, 0, 0, 255])));
assert_eq!(result.colors.get("{rgb}"), Some(&Rgba([0, 255, 0, 255])));
assert_eq!(result.colors.get("{hsl}"), Some(&Rgba([0, 0, 255, 255])));
assert_eq!(result.colors.get("{named}"), Some(&Rgba([255, 127, 80, 255])));
}
#[test]
fn test_simple_var_reference() {
let raw = make_palette(&[("--primary", "#FF0000"), ("{red}", "var(--primary)")]);
let parser = PaletteParser::new();
let result = parser.parse(&raw, ParseMode::Lenient).unwrap();
assert_eq!(result.colors.get("{red}"), Some(&Rgba([255, 0, 0, 255])));
assert!(result.warnings.is_empty());
assert!(!result.colors.contains_key("--primary"));
}
#[test]
fn test_var_with_fallback() {
let raw = make_palette(&[("{color}", "var(--missing, #00FF00)")]);
let parser = PaletteParser::new();
let result = parser.parse(&raw, ParseMode::Lenient).unwrap();
assert_eq!(result.colors.get("{color}"), Some(&Rgba([0, 255, 0, 255])));
assert!(result.warnings.is_empty());
}
#[test]
fn test_nested_var_reference() {
let raw = make_palette(&[
("--base", "#FF0000"),
("--primary", "var(--base)"),
("{color}", "var(--primary)"),
]);
let parser = PaletteParser::new();
let result = parser.parse(&raw, ParseMode::Lenient).unwrap();
assert_eq!(result.colors.get("{color}"), Some(&Rgba([255, 0, 0, 255])));
}
#[test]
fn test_forward_reference() {
let raw = make_palette(&[("{color}", "var(--primary)"), ("--primary", "#0000FF")]);
let parser = PaletteParser::new();
let result = parser.parse(&raw, ParseMode::Lenient).unwrap();
assert_eq!(result.colors.get("{color}"), Some(&Rgba([0, 0, 255, 255])));
}
#[test]
fn test_multiple_var_in_value() {
let raw = make_palette(&[
("--r", "255"),
("--g", "128"),
("--b", "0"),
("{color}", "rgb(var(--r), var(--g), var(--b))"),
]);
let parser = PaletteParser::new();
let result = parser.parse(&raw, ParseMode::Lenient).unwrap();
assert_eq!(result.colors.get("{color}"), Some(&Rgba([255, 128, 0, 255])));
}
#[test]
fn test_lenient_undefined_var() {
let raw = make_palette(&[("{color}", "var(--undefined)")]);
let parser = PaletteParser::new();
let result = parser.parse(&raw, ParseMode::Lenient).unwrap();
assert_eq!(result.colors.get("{color}"), Some(&MAGENTA));
assert_eq!(result.warnings.len(), 1);
assert!(result.warnings[0].message.contains("undefined"));
}
#[test]
fn test_lenient_invalid_color() {
let raw = make_palette(&[("{valid}", "#FF0000"), ("{invalid}", "not-a-color")]);
let parser = PaletteParser::new();
let result = parser.parse(&raw, ParseMode::Lenient).unwrap();
assert_eq!(result.colors.get("{valid}"), Some(&Rgba([255, 0, 0, 255])));
assert_eq!(result.colors.get("{invalid}"), Some(&MAGENTA));
assert_eq!(result.warnings.len(), 1);
}
#[test]
fn test_lenient_multiple_errors() {
let raw = make_palette(&[
("{good}", "#FF0000"),
("{bad1}", "var(--undefined)"),
("{bad2}", "invalid-color"),
]);
let parser = PaletteParser::new();
let result = parser.parse(&raw, ParseMode::Lenient).unwrap();
assert_eq!(result.colors.get("{good}"), Some(&Rgba([255, 0, 0, 255])));
assert_eq!(result.colors.get("{bad1}"), Some(&MAGENTA));
assert_eq!(result.colors.get("{bad2}"), Some(&MAGENTA));
assert_eq!(result.warnings.len(), 2);
}
#[test]
fn test_strict_undefined_var() {
let raw = make_palette(&[("{color}", "var(--undefined)")]);
let parser = PaletteParser::new();
let result = parser.parse(&raw, ParseMode::Strict);
assert!(result.is_err());
match result.unwrap_err() {
PaletteParseError::VariableError { token, .. } => {
assert_eq!(token, "{color}");
}
_ => panic!("Expected VariableError"),
}
}
#[test]
fn test_strict_invalid_color() {
let raw = make_palette(&[("{color}", "not-a-color")]);
let parser = PaletteParser::new();
let result = parser.parse(&raw, ParseMode::Strict);
assert!(result.is_err());
match result.unwrap_err() {
PaletteParseError::ColorError { token, .. } => {
assert_eq!(token, "{color}");
}
_ => panic!("Expected ColorError"),
}
}
#[test]
fn test_strict_stops_on_first_error() {
let raw = make_palette(&[("{bad1}", "var(--undefined)"), ("{bad2}", "also-undefined")]);
let parser = PaletteParser::new();
let result = parser.parse(&raw, ParseMode::Strict);
assert!(result.is_err());
}
#[test]
fn test_external_variables() {
let mut external = VariableRegistry::new();
external.define("--global", "#00FF00");
let raw = make_palette(&[("{color}", "var(--global)")]);
let parser = PaletteParser::with_external_vars(external);
let result = parser.parse(&raw, ParseMode::Lenient).unwrap();
assert_eq!(result.colors.get("{color}"), Some(&Rgba([0, 255, 0, 255])));
}
#[test]
fn test_local_overrides_external() {
let mut external = VariableRegistry::new();
external.define("--color", "#FF0000");
let raw = make_palette(&[
("--color", "#00FF00"), ("{token}", "var(--color)"),
]);
let parser = PaletteParser::with_external_vars(external);
let result = parser.parse(&raw, ParseMode::Lenient).unwrap();
assert_eq!(result.colors.get("{token}"), Some(&Rgba([0, 255, 0, 255])));
}
#[test]
fn test_resolve_to_strings() {
let raw = make_palette(&[
("--primary", "#FF0000"),
("{color}", "var(--primary)"),
("{static}", "#00FF00"),
]);
let parser = PaletteParser::new();
let result = parser.resolve_to_strings(&raw, ParseMode::Lenient).unwrap();
assert_eq!(result.colors.get("{color}"), Some(&"#FF0000".to_string()));
assert_eq!(result.colors.get("{static}"), Some(&"#00FF00".to_string()));
}
#[test]
fn test_error_display() {
let var_err = PaletteParseError::VariableError {
token: "{test}".to_string(),
error: VariableError::Undefined("--missing".to_string()),
};
let display = format!("{}", var_err);
assert!(display.contains("{test}"));
assert!(display.contains("--missing"));
let color_err = PaletteParseError::ColorError {
token: "{test}".to_string(),
value: "bad".to_string(),
error: ColorError::CssParse("invalid".to_string()),
};
let display = format!("{}", color_err);
assert!(display.contains("{test}"));
assert!(display.contains("bad"));
}
#[test]
fn test_circular_var_reference_lenient() {
let raw =
make_palette(&[("--a", "var(--b)"), ("--b", "var(--a)"), ("{color}", "var(--a)")]);
let parser = PaletteParser::new();
let result = parser.parse(&raw, ParseMode::Lenient).unwrap();
assert_eq!(result.colors.get("{color}"), Some(&MAGENTA));
assert!(!result.warnings.is_empty());
}
#[test]
fn test_circular_var_reference_strict() {
let raw =
make_palette(&[("--a", "var(--b)"), ("--b", "var(--a)"), ("{color}", "var(--a)")]);
let parser = PaletteParser::new();
let result = parser.parse(&raw, ParseMode::Strict);
assert!(result.is_err());
}
#[test]
fn test_empty_palette() {
let raw = HashMap::new();
let parser = PaletteParser::new();
let result = parser.parse(&raw, ParseMode::Lenient).unwrap();
assert!(result.colors.is_empty());
assert!(result.warnings.is_empty());
}
#[test]
fn test_only_variables() {
let raw = make_palette(&[("--a", "#FF0000"), ("--b", "#00FF00")]);
let parser = PaletteParser::new();
let result = parser.parse(&raw, ParseMode::Lenient).unwrap();
assert!(result.colors.is_empty());
assert_eq!(result.variables.len(), 2);
}
#[test]
fn test_var_without_dashes_in_name() {
let raw = make_palette(&[("--primary", "#FF0000"), ("{color}", "var(primary)")]);
let parser = PaletteParser::new();
let result = parser.parse(&raw, ParseMode::Lenient).unwrap();
assert_eq!(result.colors.get("{color}"), Some(&Rgba([255, 0, 0, 255])));
}
#[test]
fn test_complex_fallback_chain() {
let raw = make_palette(&[
("--deep", "#FF0000"),
("{color}", "var(--missing, var(--also-missing, var(--deep)))"),
]);
let parser = PaletteParser::new();
let result = parser.parse(&raw, ParseMode::Lenient).unwrap();
assert_eq!(result.colors.get("{color}"), Some(&Rgba([255, 0, 0, 255])));
}
#[test]
fn test_preserve_variable_registry() {
let raw = make_palette(&[
("--primary", "#FF0000"),
("--secondary", "var(--primary)"),
("{color}", "var(--secondary)"),
]);
let parser = PaletteParser::new();
let result = parser.parse(&raw, ParseMode::Lenient).unwrap();
assert!(result.variables.contains("--primary"));
assert!(result.variables.contains("--secondary"));
assert_eq!(result.variables.resolve_var("--primary").unwrap(), "#FF0000");
}
}