use std::fmt::Write as _;
use std::sync::{Arc, OnceLock};
use indexmap::IndexMap;
use crate::ast::{
ErrorLevel, Function, Location, Node, NodeType, PathSegment, ValidationError, Value, Variable,
};
use crate::validate::schema_types::{Class, Id};
use crate::validate::{
Config, RenderPolicy, Schema, SchemaAttribute, SchemaMatches, ValidationType, Variables,
type_to_string,
};
#[derive(Clone, Debug, PartialEq)]
pub struct ValidateError<'a> {
pub node_type: NodeType,
pub lines: Vec<usize>,
pub location: Option<Location<'a>>,
pub error: ValidationError<'a>,
}
#[must_use]
pub fn global_attributes() -> &'static IndexMap<String, SchemaAttribute> {
static GLOBAL: OnceLock<IndexMap<String, SchemaAttribute>> = OnceLock::new();
GLOBAL.get_or_init(|| {
let mut attributes = IndexMap::new();
attributes.insert(
"class".to_string(),
SchemaAttribute {
attribute_type: Some(ValidationType::Custom(Arc::new(Class))),
render: RenderPolicy::Named,
..SchemaAttribute::default()
},
);
attributes.insert(
"id".to_string(),
SchemaAttribute {
attribute_type: Some(ValidationType::Custom(Arc::new(Id))),
render: RenderPolicy::Named,
..SchemaAttribute::default()
},
);
attributes
})
}
#[derive(Debug)]
#[non_exhaustive]
pub enum TypeCheck<'a> {
Valid,
Invalid,
Errors(Vec<ValidationError<'a>>),
}
#[must_use]
pub fn validate_type<'a>(
value_type: &ValidationType,
value: &Value,
config: &Config<'a>,
key: &str,
) -> TypeCheck<'a> {
if let Value::Function(function) = value
&& config.validation.validate_functions
{
let Some(schema) = config.functions.get(function.name.as_str()) else {
return TypeCheck::Valid;
};
let Some(returns) = &schema.returns else {
return TypeCheck::Valid;
};
let matched = match returns {
ValidationType::Union(members) => {
members.iter().any(|member| member.is_same_type(value_type))
}
single => single.is_same_type(value_type),
};
return if matched {
TypeCheck::Valid
} else {
TypeCheck::Invalid
};
}
if matches!(value, Value::Function(_) | Value::Variable(_)) {
return TypeCheck::Valid;
}
match value_type {
ValidationType::Union(members) => {
let satisfied = members.iter().any(|member| {
!matches!(
validate_type(member, value, config, key),
TypeCheck::Invalid
)
});
if satisfied {
TypeCheck::Valid
} else {
TypeCheck::Invalid
}
}
ValidationType::Custom(custom) => match custom.validate(value, config, key) {
Some(errors) => TypeCheck::Errors(errors),
None => TypeCheck::Invalid,
},
primitive => {
if primitive.accepts_shape(value) {
TypeCheck::Valid
} else {
TypeCheck::Invalid
}
}
}
}
fn validate_function<'a>(function: &Function, config: &Config<'a>) -> Vec<ValidationError<'a>> {
let mut errors = Vec::new();
let Some(schema) = config.functions.get(function.name.as_str()) else {
return vec![ValidationError::new(
"function-undefined",
ErrorLevel::Critical,
format!("Undefined function: '{}'", function.name),
)];
};
if let Some(hook) = &schema.validate {
errors.extend(hook(function, config));
}
if let Some(parameters) = &schema.parameters {
for (key, value) in &function.parameters {
let Some(parameter) = parameters.get(key.as_str()) else {
errors.push(ValidationError::new(
"parameter-undefined",
ErrorLevel::Error,
format!("Invalid parameter: '{key}'"),
));
continue;
};
if matches!(value, Value::Variable(_)) {
continue;
}
if let Some(value_type) = ¶meter.attribute_type {
match validate_type(value_type, value, config, key) {
TypeCheck::Valid => {}
TypeCheck::Invalid => errors.push(ValidationError::new(
"parameter-type-invalid",
ErrorLevel::Error,
format!(
"Parameter '{key}' of '{}' must be type of '{}'",
function.name,
type_to_string(value_type)
),
)),
TypeCheck::Errors(found) => errors.extend(found),
}
}
}
}
for (key, parameter) in schema.parameters.iter().flatten() {
if parameter.required && !function.parameters.contains_key(key.as_str()) {
errors.push(ValidationError::new(
"parameter-missing-required",
ErrorLevel::Error,
format!("Missing required parameter: '{key}'"),
));
}
}
errors
}
#[must_use]
pub fn validator<'a>(node: &'a Node<'a>, config: &Config<'a>) -> Vec<ValidationError<'a>> {
let mut errors: Vec<ValidationError<'a>> = node.errors.clone();
let Some(schema) = config.find_schema(node) else {
errors.push(match &node.tag {
Some(tag) => ValidationError::new(
"tag-undefined",
ErrorLevel::Critical,
format!("Undefined tag: '{tag}'"),
),
None => ValidationError::new(
"node-undefined",
ErrorLevel::Critical,
format!("Undefined node: '{}'", node.node_type),
),
});
return errors;
};
if let Some(inline) = schema.inline
&& node.inline != inline
{
errors.push(ValidationError::new(
"tag-placement-invalid",
ErrorLevel::Critical,
format!(
"'{}' tag should be {}",
node.tag.as_deref().unwrap_or_default(),
if inline { "inline" } else { "block" }
),
));
}
if schema.self_closing && !node.children.is_empty() {
errors.push(ValidationError::new(
"tag-selfclosing-has-children",
ErrorLevel::Critical,
format!(
"'{}' tag should be self-closing",
node.tag.as_deref().unwrap_or_default()
),
));
}
let attributes = merged_attributes(schema);
for key in node.slots.keys() {
if !schema.slots.contains_key(key.as_str()) {
errors.push(ValidationError::new(
"slot-undefined",
ErrorLevel::Error,
format!("Invalid slot: '{key}'"),
));
}
}
for (key, value) in &node.attributes {
validate_attribute(&attributes, key, value, config, &mut errors);
}
for (key, attribute) in &attributes {
if attribute.required && !node.attributes.contains_key(key.as_str()) {
errors.push(ValidationError::new(
"attribute-missing-required",
ErrorLevel::Error,
format!("Missing required attribute: '{key}'"),
));
}
}
for (key, slot) in &schema.slots {
if slot.required && !node.slots.contains_key(key.as_str()) {
errors.push(ValidationError::new(
"slot-missing-required",
ErrorLevel::Error,
format!("Missing required slot: '{key}'"),
));
}
}
if let Some(allowed) = &schema.children {
for child in &node.children {
if child.node_type != NodeType::Error && !allowed.contains(&child.node_type) {
errors.push(ValidationError::new(
"child-invalid",
ErrorLevel::Warning,
format!("Can't nest '{}' in '{}'", child.node_type, node.name()),
));
}
}
}
if let Some(hook) = &schema.validate {
errors.extend(hook(node, config));
}
errors
}
fn merged_attributes(schema: &Schema) -> IndexMap<String, SchemaAttribute> {
let mut merged = global_attributes().clone();
for (key, attribute) in &schema.attributes {
merged.insert(key.clone(), attribute.clone());
}
merged
}
fn validate_attribute<'a>(
attributes: &IndexMap<String, SchemaAttribute>,
key: &str,
value: &Value,
config: &Config<'a>,
errors: &mut Vec<ValidationError<'a>>,
) {
let Some(attribute) = attributes.get(key) else {
errors.push(ValidationError::new(
"attribute-undefined",
ErrorLevel::Error,
format!("Invalid attribute: '{key}'"),
));
return;
};
match value {
Value::Function(function) if config.validation.validate_functions => {
errors.extend(validate_function(function, config));
}
Value::Variable(variable) => match &config.variables {
Some(variables) => errors.extend(undefined_variable(variable, variables)),
None => return,
},
Value::Function(_) => return,
_ => {}
}
let level = attribute.error_level.unwrap_or(ErrorLevel::Error);
if let Some(value_type) = &attribute.attribute_type {
match validate_type(value_type, value, config, key) {
TypeCheck::Valid => {}
TypeCheck::Invalid => errors.push(ValidationError::new(
"attribute-type-invalid",
level,
format!(
"Attribute '{key}' must be type of '{}'",
type_to_string(value_type)
),
)),
TypeCheck::Errors(found) => errors.extend(found),
}
}
if let Some(matches) = resolve_matches(attribute.matches.as_ref(), config) {
match matches {
SchemaMatches::Values(allowed) => {
let member = matches!(value, Value::String(text) if allowed.contains(text));
if !member {
errors.push(ValidationError::new(
"attribute-value-invalid",
level,
format!(
"Attribute '{key}' must match one of {}. Got '{}' instead.",
display_matches(&allowed, 8),
js_string(value)
),
));
}
}
SchemaMatches::Pattern(pattern) => {
if !pattern.is_match(&js_string(value)) {
errors.push(ValidationError::new(
"attribute-value-invalid",
level,
format!(
"Attribute '{key}' must match {}. Got '{}' instead.",
pattern.display(),
js_string(value)
),
));
}
}
SchemaMatches::Dynamic(_) => {}
}
}
if let Some(hook) = &attribute.validate {
errors.extend(hook(value, config, key));
}
}
fn resolve_matches(matches: Option<&SchemaMatches>, config: &Config<'_>) -> Option<SchemaMatches> {
match matches? {
SchemaMatches::Dynamic(hook) => hook(config),
other => Some(other.clone()),
}
}
fn undefined_variable<'a>(
variable: &Variable,
variables: &Variables,
) -> Option<ValidationError<'a>> {
let mut current: Option<&Value> = None;
for segment in &variable.path {
let found = match current {
None => match segment {
PathSegment::Key(key) => variables.get(key.as_str()),
PathSegment::Index(index) => variables.get(index_key(*index).as_str()),
},
Some(Value::Hash(entries)) => match segment {
PathSegment::Key(key) => entries.get(key.as_str()),
PathSegment::Index(index) => entries.get(index_key(*index).as_str()),
},
Some(Value::Array(items)) => array_element(items, segment),
Some(_) => None,
};
let Some(found) = found else {
return Some(ValidationError::new(
"variable-undefined",
ErrorLevel::Error,
format!("Undefined variable: '{}'", path_to_string(&variable.path)),
));
};
current = Some(found);
}
None
}
fn array_element<'v>(items: &'v [Value], segment: &PathSegment) -> Option<&'v Value> {
let index = match segment {
PathSegment::Index(index) => *index,
PathSegment::Key(key) => key.parse::<f64>().ok()?,
};
if !(0.0..=f64::from(u32::MAX)).contains(&index) || index.fract() != 0.0 {
return None;
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let index = index as usize;
items.get(index)
}
fn index_key(index: f64) -> String {
js_number(index)
}
fn path_to_string(path: &[PathSegment]) -> String {
path.iter()
.map(|segment| match segment {
PathSegment::Key(key) => key.clone(),
PathSegment::Index(index) => js_number(*index),
})
.collect::<Vec<_>>()
.join(".")
}
fn display_matches(matches: &[String], n: usize) -> String {
if matches.len() <= n {
return format!(
"[{}]",
matches
.iter()
.map(|item| json_string(item))
.collect::<Vec<_>>()
.join(",")
);
}
let shown = matches
.iter()
.take(n)
.map(|item| json_string(item))
.collect::<Vec<_>>()
.join(",");
format!("[{shown}, ... {} more]", matches.len() - n)
}
fn json_string(text: &str) -> String {
let mut out = String::with_capacity(text.len() + 2);
out.push('"');
for character in text.chars() {
match character {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
'\u{8}' => out.push_str("\\b"),
'\u{c}' => out.push_str("\\f"),
c if (c as u32) < 0x20 => {
let _ = write!(out, "\\u{:04x}", c as u32);
}
c => out.push(c),
}
}
out.push('"');
out
}
fn js_string(value: &Value) -> String {
match value {
Value::Null => "null".to_string(),
Value::Boolean(boolean) => boolean.to_string(),
Value::Number(number) => js_number(*number),
Value::String(text) => text.clone(),
Value::Array(items) => items
.iter()
.map(|item| match item {
Value::Null => String::new(),
other => js_string(other),
})
.collect::<Vec<_>>()
.join(","),
Value::Hash(_) | Value::Function(_) | Value::Variable(_) => "[object Object]".to_string(),
}
}
fn js_number(number: f64) -> String {
if number.is_nan() {
return "NaN".to_string();
}
if number.is_infinite() {
return if number > 0.0 {
"Infinity"
} else {
"-Infinity"
}
.to_string();
}
format!("{number}")
}
pub fn walk_with_parents<'n, 'a, F>(node: &'n Node<'a>, mut visit: F)
where
F: FnMut(&'n Node<'a>, &[&'n Node<'a>]),
{
enum Step<'n, 'a> {
Visit(&'n Node<'a>),
Leave,
}
let mut stack = vec![Step::Visit(node)];
let mut path: Vec<&'n Node<'a>> = Vec::new();
while let Some(step) = stack.pop() {
match step {
Step::Leave => {
path.pop();
}
Step::Visit(current) => {
visit(current, &path);
let descendants: Vec<&'n Node<'a>> = current
.slots
.values()
.chain(current.children.iter())
.collect();
if !descendants.is_empty() {
stack.push(Step::Leave);
path.push(current);
stack.extend(descendants.into_iter().rev().map(Step::Visit));
}
}
}
}
}
#[must_use]
pub fn validate_tree<'a>(content: &'a Node<'a>, config: &Config<'a>) -> Vec<ValidateError<'a>> {
let mut scoped = config.clone();
let mut output = Vec::new();
walk_with_parents(content, |node, parents| {
scoped.validation.parents.clear();
scoped.validation.parents.extend_from_slice(parents);
for error in validator(node, &scoped) {
output.push(to_validate_error(node, error));
}
});
output
}
fn to_validate_error<'a>(node: &Node<'a>, error: ValidationError<'a>) -> ValidateError<'a> {
match error.location {
Some(location) => {
let file = location.file.or_else(|| node.location.and_then(|l| l.file));
ValidateError {
node_type: node.node_type,
lines: vec![location.start.line, location.end.line],
location: Some(Location { file, ..location }),
error,
}
}
None => ValidateError {
node_type: node.node_type,
lines: node.lines.clone(),
location: node.location,
error,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_value_prints_as_javascript_prints_it() {
assert_eq!(js_string(&Value::Null), "null");
assert_eq!(js_string(&Value::Boolean(true)), "true");
assert_eq!(js_string(&Value::Number(1.0)), "1");
assert_eq!(js_string(&Value::Number(1.5)), "1.5");
assert_eq!(
js_string(&Value::Array(vec![
Value::Null,
Value::Number(1.0),
Value::String("a".into())
])),
",1,a"
);
assert_eq!(
js_string(&Value::Variable(Variable::default())),
"[object Object]"
);
}
#[test]
fn matches_are_elided_the_way_upstream_elides_them() {
let short: Vec<String> = ["bar", "baz", "bat"]
.iter()
.map(|s| (*s).to_string())
.collect();
assert_eq!(display_matches(&short, 8), r#"["bar","baz","bat"]"#);
let long: Vec<String> = "foobarbazqux".chars().map(|c| c.to_string()).collect();
assert_eq!(
display_matches(&long, 8),
r#"["f","o","o","b","a","r","b","a", ... 4 more]"#
);
}
#[test]
fn json_strings_escape_what_json_escapes() {
assert_eq!(json_string("a\"b\\c"), r#""a\"b\\c""#);
assert_eq!(json_string("a\nb"), r#""a\nb""#);
assert_eq!(json_string("\u{1}"), "\"\\u0001\"");
}
#[test]
fn the_walk_is_iterative_and_carries_the_path() {
let mut node = Node::new(NodeType::Document);
for _ in 0..50_000 {
node = Node::with(NodeType::Tag, IndexMap::new(), vec![node], None);
}
let mut deepest = 0;
let mut visited = 0;
walk_with_parents(&node, |_, parents| {
visited += 1;
deepest = deepest.max(parents.len());
});
assert_eq!(visited, 50_001);
assert_eq!(deepest, 50_000);
}
#[test]
fn the_walk_visits_slots_before_children() {
let mut tag = Node::with(
NodeType::Tag,
IndexMap::new(),
vec![Node::new(NodeType::Heading)],
Some("example".into()),
);
tag.slots
.insert("foo".to_string(), Node::new(NodeType::Paragraph));
let document = Node::with(NodeType::Document, IndexMap::new(), vec![tag], None);
let mut seen = Vec::new();
walk_with_parents(&document, |node, _| seen.push(node.name().to_string()));
assert_eq!(seen, ["document", "example", "paragraph", "heading"]);
}
}