use crate::ast::{ErrorLevel, ValidationError, Value};
use crate::renderable::Scalar;
use crate::validate::{AttributeType, Config};
#[derive(Clone, Copy, Debug, Default)]
pub struct Class;
impl AttributeType for Class {
fn name(&self) -> &'static str {
"Class"
}
fn validate<'a>(
&self,
value: &Value,
_config: &Config<'a>,
name: &str,
) -> Option<Vec<ValidationError<'a>>> {
match value {
Value::String(_)
| Value::Hash(_)
| Value::Array(_)
| Value::Null
| Value::Function(_)
| Value::Variable(_) => Some(Vec::new()),
Value::Boolean(_) | Value::Number(_) => Some(vec![ValidationError::new(
"attribute-type-invalid",
ErrorLevel::Error,
format!("Attribute '{name}' must be type 'string | object'"),
)]),
}
}
fn transform(&self, value: Option<&Value>, _config: &Config<'_>) -> Option<Scalar> {
match value {
None => None,
Some(value @ (Value::String(_) | Value::Null)) => Scalar::from_value(value),
Some(Value::Hash(entries)) => {
let names: Vec<&str> = entries
.iter()
.filter(|(_, value)| value.is_truthy())
.map(|(key, _)| key.as_str())
.collect();
Some(Scalar::String(names.join(" ")))
}
Some(value) if !value.is_truthy() => Scalar::from_value(value),
Some(_) => Some(Scalar::String(String::new())),
}
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct Id;
impl AttributeType for Id {
fn name(&self) -> &'static str {
"Id"
}
fn validate<'a>(
&self,
value: &Value,
_config: &Config<'a>,
_name: &str,
) -> Option<Vec<ValidationError<'a>>> {
let valid = match value {
Value::String(text) => text
.chars()
.next()
.is_some_and(|first| first.is_ascii_alphabetic()),
_ => false,
};
if valid {
return Some(Vec::new());
}
Some(vec![ValidationError::new(
"attribute-value-invalid",
ErrorLevel::Error,
"The 'id' attribute must start with a letter",
)])
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct Conditional;
impl AttributeType for Conditional {
fn name(&self) -> &'static str {
"ConditionalAttributeType"
}
fn validate<'a>(
&self,
value: &Value,
_config: &Config<'a>,
name: &str,
) -> Option<Vec<ValidationError<'a>>> {
match value {
Value::Boolean(_)
| Value::Null
| Value::Hash(_)
| Value::Array(_)
| Value::Function(_)
| Value::Variable(_) => Some(Vec::new()),
Value::String(_) | Value::Number(_) => Some(vec![ValidationError::new(
"attribute-type-invalid",
ErrorLevel::Error,
format!(
"Attribute '{name}' must be type 'boolean | object' \
(null or undefined are also allowed)"
),
)]),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use indexmap::IndexMap;
fn errors(result: Option<Vec<ValidationError<'_>>>) -> Vec<ValidationError<'_>> {
result.expect("these types all declare validation")
}
#[test]
fn class_accepts_a_string_or_an_object() {
let config = Config::new();
assert!(errors(Class.validate(&Value::String("a b".into()), &config, "class")).is_empty());
assert!(errors(Class.validate(&Value::Hash(IndexMap::new()), &config, "class")).is_empty());
let rejected = errors(Class.validate(&Value::Number(1.0), &config, "class"));
assert_eq!(
rejected.first().map(|e| e.id),
Some("attribute-type-invalid")
);
assert_eq!(
rejected.first().map(|e| e.message.as_str()),
Some("Attribute 'class' must be type 'string | object'")
);
}
#[test]
fn class_joins_the_truthy_keys_of_an_object() {
let config = Config::new();
let mut hash = IndexMap::new();
hash.insert("active".to_string(), Value::Boolean(true));
hash.insert("hidden".to_string(), Value::Boolean(false));
hash.insert("large".to_string(), Value::Number(1.0));
assert_eq!(
Class.transform(Some(&Value::Hash(hash)), &config),
Some(Scalar::String("active large".to_string()))
);
assert_eq!(
Class.transform(Some(&Value::String("a b".into())), &config),
Some(Scalar::String("a b".to_string()))
);
}
#[test]
fn an_id_must_start_with_an_ascii_letter() {
let config = Config::new();
assert!(errors(Id.validate(&Value::String("bar".into()), &config, "id")).is_empty());
for rejected in ["1bar", "#bar", "", "\u{e9}bar"] {
let found = errors(Id.validate(&Value::String(rejected.into()), &config, "id"));
assert_eq!(
found.first().map(|e| e.id),
Some("attribute-value-invalid"),
"{rejected:?} should be rejected"
);
assert_eq!(
found.first().map(|e| e.message.as_str()),
Some("The 'id' attribute must start with a letter")
);
}
}
#[test]
fn a_condition_may_be_absent_without_being_wrong() {
let config = Config::new();
assert!(errors(Conditional.validate(&Value::Null, &config, "primary")).is_empty());
assert!(
errors(Conditional.validate(&Value::Boolean(false), &config, "primary")).is_empty()
);
let rejected =
errors(Conditional.validate(&Value::String("yes".into()), &config, "primary"));
assert_eq!(
rejected.first().map(|e| e.message.as_str()),
Some(
"Attribute 'primary' must be type 'boolean | object' \
(null or undefined are also allowed)"
)
);
}
}