use std::sync::Arc;
use indexmap::IndexMap;
use accent_proust::ast::{ErrorLevel, Node, NodeType, ValidationError, Value};
use accent_proust::parse::parse;
use accent_proust::validate::{
AttributeType, Config, ConfigFunction, MapSchemaSource, RenderPolicy, Schema, SchemaAttribute,
ValidationType, Variables, validate_tree,
};
fn nodes() -> IndexMap<NodeType, Schema> {
let mut nodes = IndexMap::new();
nodes.insert(NodeType::Document, Schema::new());
nodes.insert(NodeType::Paragraph, Schema::new());
nodes.insert(NodeType::Inline, Schema::new());
nodes.insert(
NodeType::Text,
Schema::new().attribute("content", hidden(required(string()))),
);
nodes.insert(
NodeType::Heading,
Schema::new().attribute("level", hidden(required(number()))),
);
nodes.insert(
NodeType::Fence,
Schema::new()
.attribute("content", hidden(required(string())))
.attribute("language", string()),
);
nodes
}
fn config() -> Config<'static> {
Config::new().with_schemas(Arc::new(schemas(vec![])))
}
fn string() -> SchemaAttribute {
typed(ValidationType::String)
}
fn number() -> SchemaAttribute {
typed(ValidationType::Number)
}
fn typed(attribute_type: ValidationType) -> SchemaAttribute {
SchemaAttribute {
attribute_type: Some(attribute_type),
..SchemaAttribute::default()
}
}
fn required(mut attribute: SchemaAttribute) -> SchemaAttribute {
attribute.required = true;
attribute
}
fn hidden(mut attribute: SchemaAttribute) -> SchemaAttribute {
attribute.render = RenderPolicy::Hidden;
attribute
}
fn schemas(pairs: Vec<(&str, Schema)>) -> MapSchemaSource {
let mut schemas = MapSchemaSource::new();
schemas.nodes_mut().extend(nodes());
for (name, schema) in pairs {
schemas.insert_tag(name, schema);
}
schemas
}
fn functions(pairs: Vec<(&str, ConfigFunction)>) -> IndexMap<String, ConfigFunction> {
pairs
.into_iter()
.map(|(name, function)| (name.to_string(), function))
.collect()
}
fn parameters(pairs: Vec<(&str, SchemaAttribute)>) -> IndexMap<String, SchemaAttribute> {
pairs
.into_iter()
.map(|(name, attribute)| (name.to_string(), attribute))
.collect()
}
fn errors<'a>(document: &'a Node<'a>, config: &Config<'a>) -> Vec<(&'static str, String)> {
validate_tree(document, config)
.into_iter()
.map(|found| (found.error.id, found.error.message))
.collect()
}
fn expected(pairs: &[(&'static str, &str)]) -> Vec<(&'static str, String)> {
pairs
.iter()
.map(|(id, message)| (*id, (*message).to_string()))
.collect()
}
fn function_config(functions: IndexMap<String, ConfigFunction>) -> Config<'static> {
let mut config = config();
config.validation.validate_functions = true;
config.functions = Arc::new(functions);
config.schemas = Arc::new(schemas(vec![
("foo", Schema::new().attribute("bar", string())),
(
"union-tag-1",
Schema::new()
.attribute("foo", string())
.attribute("bar", number())
.attribute("baz", typed(ValidationType::Boolean)),
),
]));
config
}
fn returns(value_type: ValidationType) -> ConfigFunction {
ConfigFunction {
returns: Some(value_type),
..ConfigFunction::default()
}
}
fn return_type_functions() -> IndexMap<String, ConfigFunction> {
functions(vec![
("baz", returns(ValidationType::String)),
("number", returns(ValidationType::Number)),
(
"nested",
ConfigFunction {
returns: Some(ValidationType::String),
parameters: Some(parameters(vec![("0", string()), ("1", number())])),
..ConfigFunction::default()
},
),
(
"withUnion",
ConfigFunction {
returns: Some(ValidationType::Union(vec![
ValidationType::String,
ValidationType::Number,
])),
parameters: Some(IndexMap::new()),
..ConfigFunction::default()
},
),
])
}
#[test]
fn ensures_that_function_exists() {
let config = function_config(IndexMap::new());
let document = parse("{% foo bar=baz() /%}");
assert_eq!(
errors(&document, &config),
expected(&[("function-undefined", "Undefined function: 'baz'")])
);
}
#[test]
fn correctly_handles_union_types() {
let config = function_config(return_type_functions());
let document = parse("{% union-tag-1 foo=withUnion() bar=withUnion() /%}");
assert!(errors(&document, &config).is_empty());
let document = parse("{% union-tag-1 foo=withUnion() bar=withUnion() baz=withUnion() /%}");
assert_eq!(
errors(&document, &config),
expected(&[(
"attribute-type-invalid",
"Attribute 'baz' must be type of 'Boolean'"
)])
);
}
#[test]
fn correctly_handles_return_types_for_nested_function_calls() {
let config = function_config(return_type_functions());
let document = parse("{% foo bar=nested(baz(), number()) /%}");
assert!(errors(&document, &config).is_empty());
let document = parse("{% foo bar=nested(number(), baz()) /%}");
assert_eq!(
errors(&document, &config),
expected(&[
(
"parameter-type-invalid",
"Parameter '0' of 'nested' must be type of 'String'"
),
(
"parameter-type-invalid",
"Parameter '1' of 'nested' must be type of 'Number'"
),
])
);
}
#[test]
fn accepts_a_correct_return_type() {
let config = function_config(return_type_functions());
let document = parse("{% foo bar=baz() /%}");
assert!(errors(&document, &config).is_empty());
}
#[test]
fn correctly_handles_no_return_type() {
let config = function_config(functions(vec![("baz", ConfigFunction::default())]));
let document = parse("{% foo bar=baz() /%}");
assert!(errors(&document, &config).is_empty());
}
#[test]
fn identifies_an_incorrect_return_type() {
let config = function_config(functions(vec![("baz", returns(ValidationType::Number))]));
let document = parse("{% foo bar=baz() /%}");
assert_eq!(
errors(&document, &config)
.into_iter()
.map(|(id, _)| id)
.collect::<Vec<_>>(),
["attribute-type-invalid"]
);
}
fn parameter_functions() -> IndexMap<String, ConfigFunction> {
functions(vec![
(
"baz",
ConfigFunction {
returns: Some(ValidationType::String),
parameters: Some(IndexMap::new()),
..ConfigFunction::default()
},
),
(
"qux",
ConfigFunction {
returns: Some(ValidationType::String),
parameters: Some(parameters(vec![("test", string())])),
..ConfigFunction::default()
},
),
("noTyping", ConfigFunction::default()),
(
"requiredParam",
ConfigFunction {
returns: Some(ValidationType::String),
parameters: Some(parameters(vec![
("test", string()),
("req", required(string())),
])),
..ConfigFunction::default()
},
),
])
}
#[test]
fn with_a_missing_optional_parameter() {
let config = function_config(parameter_functions());
let document = parse("{% foo bar=qux() /%}");
assert!(errors(&document, &config).is_empty());
}
#[test]
fn with_a_missing_required_parameter() {
let config = function_config(parameter_functions());
let document = parse("{% foo bar=requiredParam() /%}");
assert_eq!(
errors(&document, &config),
expected(&[(
"parameter-missing-required",
"Missing required parameter: 'req'"
)])
);
}
#[test]
fn accepts_defined_parameters_with_a_keyed_parameter() {
let config = function_config(parameter_functions());
let document = parse(r#"{% foo bar=qux(test="example") /%}"#);
assert!(errors(&document, &config).is_empty());
}
#[test]
fn ignores_parameters_when_there_is_no_typing() {
let config = function_config(parameter_functions());
let document = parse("{% foo bar=noTyping(foo=1) /%}");
assert!(errors(&document, &config).is_empty());
}
#[test]
fn rejects_undeclared_parameters_with_a_keyed_parameter() {
let config = function_config(parameter_functions());
let document = parse("{% foo bar=baz(foo=1) /%}");
assert_eq!(
errors(&document, &config),
expected(&[("parameter-undefined", "Invalid parameter: 'foo'")])
);
}
#[test]
fn rejects_undeclared_parameters_with_a_positional_parameter() {
let config = function_config(parameter_functions());
let document = parse("{% foo bar=baz(1) /%}");
assert_eq!(
errors(&document, &config),
expected(&[("parameter-undefined", "Invalid parameter: '0'")])
);
let document = parse("{% foo bar=baz(1, test=2) /%}");
assert_eq!(
errors(&document, &config),
expected(&[
("parameter-undefined", "Invalid parameter: '0'"),
("parameter-undefined", "Invalid parameter: 'test'"),
])
);
}
fn inline_config() -> Config<'static> {
let mut config = config();
config.schemas = Arc::new(schemas(vec![
(
"foo",
Schema {
inline: Some(true),
..Schema::new()
},
),
(
"bar",
Schema {
inline: Some(false),
..Schema::new()
},
),
("baz", Schema::new()),
]));
config
}
#[test]
fn allows_inline_or_block_when_undefined() {
let config = inline_config();
let document = parse("this is inline {% baz %}bar{% /baz %}");
assert!(errors(&document, &config).is_empty());
let document = parse("\n{% baz %}\nbar\n{% /baz %}\n ");
assert!(errors(&document, &config).is_empty());
}
#[test]
fn validates_inline_tag() {
let config = inline_config();
let document = parse("this is inline {% foo %}bar{% /foo %}");
assert!(errors(&document, &config).is_empty());
let document = parse("\n{% foo %}\nbar\n{% /foo %}\n ");
let found = errors(&document, &config);
assert_eq!(
found.first().map(|(id, _)| *id),
Some("tag-placement-invalid")
);
assert!(found[0].1.contains("should be inline"), "{found:?}");
}
#[test]
fn validates_block_tag() {
let config = inline_config();
let document = parse("\n{% bar %}\nbar\n{% /bar %}\n");
assert!(errors(&document, &config).is_empty());
let document = parse("this is inline {% bar %}bar{% /bar %}");
let found = errors(&document, &config);
assert_eq!(
found.first().map(|(id, _)| *id),
Some("tag-placement-invalid")
);
assert!(found[0].1.contains("should be block"), "{found:?}");
}
#[test]
fn an_attribute_validate_hook_using_a_simple_conditional() {
let mut config = config();
config.schemas = Arc::new(schemas(vec![(
"foo",
Schema::new().attribute(
"bar",
SchemaAttribute {
attribute_type: Some(ValidationType::Number),
validate: Some(Arc::new(
|value: &Value, _config: &Config<'_>, _key: &str| {
let greater = matches!(value, Value::Number(n) if *n > 10.0);
if greater {
return Vec::new();
}
vec![ValidationError::new(
"attribute-should-be-greater-than-ten",
ErrorLevel::Error,
r#"Attribute "bar" must have value greater than 10."#,
)]
},
)),
..SchemaAttribute::default()
},
),
)]));
let document = parse("{% foo bar=20 /%}");
assert!(errors(&document, &config).is_empty());
let document = parse("{% foo bar=5 /%}");
assert_eq!(
errors(&document, &config),
expected(&[(
"attribute-should-be-greater-than-ten",
r#"Attribute "bar" must have value greater than 10."#
)])
);
}
fn matches_config(allowed: Vec<&str>) -> Config<'static> {
use accent_proust::validate::SchemaMatches;
let mut config = config();
config.schemas = Arc::new(schemas(vec![(
"foo",
Schema::new().attribute(
"jawn",
SchemaAttribute {
attribute_type: Some(ValidationType::String),
matches: Some(SchemaMatches::Values(
allowed.into_iter().map(str::to_string).collect(),
)),
..SchemaAttribute::default()
},
),
)]));
config
}
#[test]
fn should_return_error_on_failure_to_match_array() {
let config = matches_config(vec!["bar", "baz", "bat"]);
let document = parse(r#"{% foo jawn="cat" /%}"#);
assert_eq!(
errors(&document, &config),
expected(&[(
"attribute-value-invalid",
r#"Attribute 'jawn' must match one of ["bar","baz","bat"]. Got 'cat' instead."#
)])
);
}
#[test]
fn elides_excess_values_in_matches_check() {
let config = matches_config("foobarbazqux".split("").filter(|s| !s.is_empty()).collect());
let document = parse(r#"{% foo jawn="cat" /%}"#);
assert_eq!(
errors(&document, &config),
expected(&[(
"attribute-value-invalid",
r#"Attribute 'jawn' must match one of ["f","o","o","b","a","r","b","a", ... 4 more]. Got 'cat' instead."#
)])
);
}
#[test]
fn properly_validates_ids() {
let config = config();
let document = parse("# foo {% #bar %}");
assert!(errors(&document, &config).is_empty());
let document = parse("# foo {% #1bar %}");
assert_eq!(
errors(&document, &config).first().map(|(id, _)| *id),
Some("attribute-value-invalid")
);
let document = parse(r##"# foo {% id="#bar" %}"##);
assert_eq!(
errors(&document, &config).first().map(|(id, _)| *id),
Some("attribute-value-invalid")
);
}
struct Link;
impl AttributeType for Link {
fn name(&self) -> &'static str {
"Link"
}
fn validate<'a>(
&self,
value: &Value,
_config: &Config<'a>,
_name: &str,
) -> Option<Vec<ValidationError<'a>>> {
if matches!(value, Value::String(text) if text.starts_with("http")) {
return Some(Vec::new());
}
Some(vec![ValidationError::new(
"attribute-type-invalid",
ErrorLevel::Error,
"Attribute 'href' must be type of 'Link'",
)])
}
}
#[test]
fn a_custom_type_returns_error_on_failure() {
let mut config = config();
config.schemas = Arc::new(schemas(vec![(
"link",
Schema::new()
.render("a")
.attribute("href", typed(ValidationType::Custom(Arc::new(Link)))),
)]));
let document = parse(r#"{% link href="/relative-link" /%}"#);
assert_eq!(
errors(&document, &config),
expected(&[(
"attribute-type-invalid",
"Attribute 'href' must be type of 'Link'"
)])
);
}
#[test]
fn a_custom_type_returns_no_errors_when_valid() {
let mut config = config();
config.schemas = Arc::new(schemas(vec![(
"link",
Schema {
self_closing: true,
..Schema::new()
.render("a")
.attribute("href", typed(ValidationType::Custom(Arc::new(Link))))
},
)]));
let document = parse(r#"{% link href="http://google.com" /%}"#);
assert!(errors(&document, &config).is_empty());
}
#[test]
fn should_only_validate_if_the_variables_config_is_passed() {
let config = config();
let document = parse("{% $valid.variable %}");
assert!(errors(&document, &config).is_empty());
}
#[test]
fn should_warn_against_missing_variables() {
let mut config = config();
config.variables = Some(Variables::new());
let document = parse("{% $undefinedVariable %}");
assert_eq!(
errors(&document, &config),
expected(&[(
"variable-undefined",
"Undefined variable: 'undefinedVariable'"
)])
);
assert_eq!(
validate_tree(&document, &config)
.first()
.map(|found| found.node_type),
Some(NodeType::Text)
);
}
#[test]
fn should_not_warn_if_variable_exists() {
let mut config = config();
let mut valid = IndexMap::new();
valid.insert("variable".to_string(), Value::Boolean(false));
let mut variables = Variables::new();
variables.insert("valid".to_string(), Value::Hash(valid));
config.variables = Some(variables);
let document = parse("{% $valid.variable %}");
assert!(errors(&document, &config).is_empty());
}
#[test]
fn should_not_error_for_missing_support_for_code_block() {
let config = config();
let document = parse(
" # https://spec.commonmark.org/0.30/#indented-code-block\n 4-space indented code",
);
assert!(errors(&document, &config).is_empty());
}
fn less_than_five<'a>(value: &Value, name: &str) -> Vec<ValidationError<'a>> {
let small = match value {
Value::Hash(entries) => matches!(entries.get("baz"), Some(Value::Number(n)) if *n < 5.0),
_ => false,
};
if !small {
return Vec::new();
}
vec![ValidationError::new(
"invalid-foo-bar",
ErrorLevel::Error,
format!("The value of '{name}.baz' must be less than five"),
)]
}
#[test]
fn an_attribute_validate_hook_receives_the_attribute_name() {
let mut config = config();
let attribute = || SchemaAttribute {
attribute_type: Some(ValidationType::Object),
validate: Some(Arc::new(
|value: &Value, _config: &Config<'_>, name: &str| less_than_five(value, name),
)),
..SchemaAttribute::default()
};
config.schemas = Arc::new(schemas(vec![(
"foo",
Schema::new()
.attribute("bar", attribute())
.attribute("blah", attribute()),
)]));
let document = parse("{% foo bar={baz: 3} /%}");
assert_eq!(
errors(&document, &config)
.first()
.map(|(_, message)| message.clone()),
Some("The value of 'bar.baz' must be less than five".to_string())
);
}
#[test]
fn a_custom_attribute_type_receives_the_attribute_name() {
struct CustomType;
impl AttributeType for CustomType {
fn name(&self) -> &'static str {
"CustomType"
}
fn validate<'a>(
&self,
value: &Value,
_config: &Config<'a>,
name: &str,
) -> Option<Vec<ValidationError<'a>>> {
Some(less_than_five(value, name))
}
}
let mut config = config();
let custom = ValidationType::Custom(Arc::new(CustomType));
config.schemas = Arc::new(schemas(vec![(
"foo",
Schema::new()
.attribute("bar", typed(custom.clone()))
.attribute("blah", typed(custom)),
)]));
let document = parse("{% foo bar={baz: 3} /%}");
assert_eq!(
errors(&document, &config)
.first()
.map(|(_, message)| message.clone()),
Some("The value of 'bar.baz' must be less than five".to_string())
);
}
#[test]
fn parent_validation_for_deep_nesting() {
let mut config = config();
let mut schemas = schemas(vec![
("foo", Schema::new()),
("bar", Schema::new()),
("baz", Schema::new()),
]);
let heading = Schema {
validate: Some(Arc::new(|_node: &Node<'_>, config: &Config<'_>| {
if config
.validation
.parents
.iter()
.any(|parent| parent.tag.as_deref() == Some("foo"))
{
return vec![ValidationError::new(
"heading-in-foo",
ErrorLevel::Error,
"Can't nest a heading in tag 'foo'",
)];
}
Vec::new()
})),
..Schema::new().attribute("level", hidden(required(number())))
};
schemas.insert_node(NodeType::Heading, heading);
config.schemas = Arc::new(schemas);
let document =
parse("\n{% foo %}\n{% bar %}\n{% baz %}\n# testing\n{% /baz %}\n{% /bar %}\n{% /foo %}\n");
let found = errors(&document, &config);
assert_eq!(found.len(), 1, "{found:?}");
assert_eq!(found[0].0, "heading-in-foo");
let document = parse(
"\n{% foo %}\n{% bar %}\n{% /bar %}\n{% /foo %}\n\n{% bar %}\n{% baz %}\n# testing\n{% /baz %}\n{% /bar %}\n",
);
assert!(errors(&document, &config).is_empty());
}
#[test]
fn parent_validation_with_function_validation_enabled() {
let mut config = config();
config.validation.validate_functions = true;
config.schemas = Arc::new(schemas(vec![
("foo", Schema::new()),
(
"bar",
Schema {
validate: Some(Arc::new(|_node: &Node<'_>, config: &Config<'_>| {
let parents: Vec<NodeType> = config
.validation
.parents
.iter()
.map(|parent| parent.node_type)
.collect();
assert_eq!(
parents,
[
NodeType::Document,
NodeType::Paragraph,
NodeType::Inline,
NodeType::Tag
]
);
Vec::new()
})),
..Schema::new()
},
),
]));
let document = parse("{% foo %}{% bar %}this is a test{% /bar %}{% /foo %}");
assert!(errors(&document, &config).is_empty());
}