use crate::{
attribute::Attribute,
datamodel::DataModel,
markdown::{frontmatter::FrontMatter, position::Position},
object::{Enumeration, Object},
xmltype::XMLType,
};
use colored::Colorize;
use log::error;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::error::Error;
use std::fmt::{Display, Formatter};
#[cfg(feature = "wasm")]
use tsify_next::Tsify;
pub(crate) const BASIC_TYPES: [&str; 7] = [
"string", "number", "integer", "boolean", "float", "date", "bytes",
];
#[derive(Debug, Clone, Serialize, PartialEq)]
#[cfg_attr(feature = "wasm", derive(Tsify))]
#[cfg_attr(feature = "wasm", tsify(into_wasm_abi))]
pub struct ValidationError {
pub message: String,
pub object: Option<String>,
pub attribute: Option<String>,
pub location: String,
pub solution: Option<String>,
pub error_type: ErrorType,
pub positions: Vec<Position>,
}
impl Display for ValidationError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
let lines: Vec<String> = self.positions.iter().map(|p| p.line.to_string()).collect();
let mut line = lines.join(", ");
if !lines.is_empty() {
line = format!("[line: {line}]");
} else {
line = "".to_string();
}
write!(
f,
"{}[{}{}] {}:\n\t└── {}\n\t {}",
line,
self.object.clone().unwrap_or("Global".into()).bold(),
match &self.attribute {
Some(attr) => format!(".{attr}"),
None => "".into(),
},
self.error_type.to_string().bold(),
self.message.red().bold(),
self.solution.clone().unwrap_or("".into()).yellow().bold(),
)?;
Ok(())
}
}
#[derive(Debug, Clone, Serialize, PartialEq, Deserialize)]
#[cfg_attr(feature = "wasm", derive(Tsify))]
#[cfg_attr(feature = "wasm", tsify(into_wasm_abi))]
pub enum ErrorType {
NameError,
TypeError,
DuplicateError,
GlobalError,
XMLError,
ObjectError,
}
impl Display for ErrorType {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
ErrorType::NameError => write!(f, "NameError"),
ErrorType::TypeError => write!(f, "TypeError"),
ErrorType::DuplicateError => write!(f, "DuplicateError"),
ErrorType::GlobalError => write!(f, "GlobalError"),
ErrorType::XMLError => write!(f, "XMLError"),
ErrorType::ObjectError => write!(f, "ObjectError"),
}
}
}
#[derive(Debug, Clone, Serialize, PartialEq)]
#[cfg_attr(feature = "wasm", derive(Tsify))]
#[cfg_attr(feature = "wasm", tsify(into_wasm_abi))]
pub struct Validator {
pub is_valid: bool,
pub errors: Vec<ValidationError>,
#[serde(skip_serializing)]
pub object_positions: HashMap<String, Vec<Position>>,
#[serde(skip_serializing)]
pub enum_positions: HashMap<String, Vec<Position>>,
}
impl Error for Validator {}
impl Display for Validator {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
for error in &self.errors {
error.fmt(f)?;
}
Ok(())
}
}
impl Validator {
pub fn new() -> Self {
Self {
is_valid: true,
errors: vec![],
object_positions: HashMap::new(),
enum_positions: HashMap::new(),
}
}
pub fn reset(&mut self) {
self.is_valid = true;
self.errors.clear();
self.object_positions.clear();
self.enum_positions.clear();
}
pub fn add_error(&mut self, error: ValidationError) {
self.errors.push(error);
self.is_valid = false;
}
pub fn log_result(&self) {
for error in &self.errors {
error!("{}", error);
}
}
pub fn validate(&mut self, model: &DataModel) {
self.reset();
self.object_positions = extract_object_positions(model);
self.enum_positions = extract_enum_positions(model);
let types = Self::extract_type_names(model);
self.check_duplicate_objects(&model.objects);
self.check_duplicate_enums(&model.enums);
self.check_has_no_objects(model);
for object in &model.objects {
self.validate_object(object, &types, &model.clone().config.unwrap_or_default());
}
self.sort_errors();
}
fn check_duplicate_objects(&mut self, collection: &[Object]) {
let unique = collection
.iter()
.map(|object| object.name.as_str())
.collect::<Vec<&str>>();
let duplicates = unique_elements(&get_duplicates(&unique));
if !duplicates.is_empty() {
for name in duplicates {
self.add_error(ValidationError {
message: format!("Object '{name}' is defined more than once."),
object: Some(name.to_string()),
attribute: None,
location: "Global".into(),
error_type: ErrorType::DuplicateError,
solution: Some(format!(
"Rename the object(s) at lines {} to be unique.",
get_line_numbers(self.object_positions.get(name).unwrap_or(&vec![]))
)),
positions: self
.object_positions
.get(name)
.cloned()
.unwrap_or_default()
.clone(),
});
}
}
}
fn check_duplicate_enums(&mut self, collection: &[Enumeration]) {
let unique = collection
.iter()
.map(|object| object.name.as_str())
.collect::<Vec<&str>>();
let duplicates = unique
.iter()
.cloned()
.filter(|&name| unique.iter().filter(|&n| n == &name).count() > 1)
.collect::<Vec<&str>>();
let duplicates = unique_elements(&duplicates);
if !duplicates.is_empty() {
for name in duplicates {
self.add_error(ValidationError {
message: format!("Enumeration '{name}' is defined more than once."),
object: Some(name.to_string()),
attribute: None,
location: "Global".into(),
error_type: ErrorType::DuplicateError,
solution: Some(format!(
"Rename the enumeration(s) at lines {} to be unique.",
get_line_numbers(self.enum_positions.get(name).unwrap_or(&vec![]))
)),
positions: self.enum_positions.get(name).cloned().unwrap_or_default(),
});
}
}
}
fn validate_object(&mut self, object: &Object, types: &[&str], frontmatter: &FrontMatter) {
self.validate_object_name(&object.name);
if !frontmatter.allow_empty {
self.check_has_attributes(object);
}
self.check_duplicate_attributes(object);
object.attributes.iter().for_each(|attribute| {
self.validate_attribute(attribute, types, object);
});
}
fn check_duplicate_attributes(&mut self, object: &Object) {
let attr_names = object
.attributes
.iter()
.map(|attribute| attribute.name.as_str())
.collect::<Vec<&str>>();
let attribute_positions = extract_attribute_positions(object);
let unique = unique_elements(&attr_names);
if attr_names.len() != unique.len() {
let duplicates = unique_elements(&get_duplicates(&attr_names));
for name in duplicates {
self.add_error(ValidationError {
message: format!("Property '{name}' is defined more than once."),
object: Some(object.name.clone()),
attribute: Some(name.to_string()),
location: "Global".into(),
error_type: ErrorType::DuplicateError,
solution: Some(format!(
"Rename the property(ies) at lines {} to be unique.",
get_line_numbers(attribute_positions.get(name).unwrap_or(&vec![]))
)),
positions: attribute_positions.get(name).cloned().unwrap_or_default(),
});
}
}
}
fn check_has_attributes(&mut self, object: &Object) {
if !object.has_attributes() {
self.add_error(ValidationError {
message: format!("Type '{}' is empty and has no properties.", object.name),
object: Some(object.name.clone()),
attribute: None,
location: "Global".into(),
error_type: ErrorType::ObjectError,
solution: Some(format!("Add a property to the object '{}'.", object.name)),
positions: self
.object_positions
.get(&object.name)
.cloned()
.unwrap_or_default(),
});
}
}
fn validate_object_name(&mut self, name: &str) {
let checks = vec![starts_with_character, contains_white_space, |name: &str| {
contains_special_characters(name, false, false)
}];
for check in checks {
if let Err((e, solution)) = check(name) {
self.add_error(ValidationError {
message: e,
object: Some(name.to_string()),
attribute: None,
solution: Some(format!("Resolve the issue by using '{solution}'.")),
location: "Global".into(),
error_type: ErrorType::NameError,
positions: self.object_positions.get(name).cloned().unwrap_or_default(),
});
}
}
}
fn check_has_no_objects(&mut self, model: &DataModel) {
if model.objects.is_empty() {
self.add_error(ValidationError {
message: "This model has no definitions.".into(),
object: Some("Model".into()),
attribute: None,
solution: Some("Add an object to the model.".into()),
location: "Global".into(),
error_type: ErrorType::GlobalError,
positions: vec![],
});
}
}
fn validate_attribute(&mut self, attribute: &Attribute, types: &[&str], object: &Object) {
self.validate_attribute_name(&attribute.name, object);
let attribute_positions = extract_attribute_positions(object);
if attribute.dtypes.is_empty() {
self.add_error(ValidationError {
message: format!("Property '{}' has no type specified.", attribute.name),
object: Some(object.name.clone()),
attribute: Some(attribute.name.clone()),
location: "Global".into(),
error_type: ErrorType::TypeError,
solution: Some(format!(
"Add a type to the property '{}' using '- {}: <TYPE>'.",
attribute.name, attribute.name
)),
positions: attribute_positions
.get(&attribute.name)
.cloned()
.unwrap_or_default(),
})
}
for dtype in &attribute.dtypes {
self.check_attr_dtype(attribute, types, object, dtype);
}
if let Some(xml_option) = &attribute.xml {
match xml_option {
XMLType::Attribute { name, .. } => {
self.validate_xml_attribute_option(name, &object.name, &attribute.name);
}
XMLType::Element { name, .. } => {
self.validate_xml_element_option(name, &object.name, &attribute.name);
}
XMLType::Wrapped { name, wrapped, .. } => {
self.validate_xml_wrapped_option(name, &object.name, &attribute.name, wrapped);
}
}
}
}
fn check_attr_dtype(
&mut self,
attribute: &Attribute,
types: &[&str],
object: &Object,
dtype: &str,
) {
let attribute_positions = extract_attribute_positions(object);
if dtype.is_empty() {
self.add_error(ValidationError {
message: format!(
"Property '{}' has no type defined. Either define a type or use a base type.",
attribute.name
),
object: Some(object.name.clone()),
attribute: Some(attribute.name.clone()),
location: "Global".into(),
error_type: ErrorType::TypeError,
solution: Some(format!(
"Add a type to the property '{}' using '- {}: TYPE' after the property name.",
attribute.name, attribute.name
)),
positions: attribute_positions
.get(&attribute.name)
.cloned()
.unwrap_or_default(),
});
return;
}
if !types.contains(&dtype) && !BASIC_TYPES.contains(&dtype) {
self.add_error(ValidationError {
message: format!(
"Type '{}' of property '{}' not found.",
dtype, attribute.name
),
object: Some(object.name.clone()),
attribute: Some(attribute.name.clone()),
location: "Global".into(),
error_type: ErrorType::TypeError,
solution: Some(format!(
"Add the type '{dtype}' to the model or use a base type."
)),
positions: attribute_positions
.get(&attribute.name)
.cloned()
.unwrap_or_default(),
})
}
}
fn validate_attribute_name(&mut self, name: &str, object: &Object) {
let checks = vec![starts_with_character, contains_white_space, |name: &str| {
contains_special_characters(name, false, true)
}];
let attribute_positions = extract_attribute_positions(object);
for check in checks {
if let Err((e, solution)) = check(name) {
self.add_error(ValidationError {
message: e,
object: Some(object.name.clone()),
attribute: Some(name.to_string()),
location: "Global".into(),
error_type: ErrorType::NameError,
solution: Some(format!("Resolve the issue by using '{solution}'.")),
positions: attribute_positions.get(name).cloned().unwrap_or_default(),
});
}
}
}
fn validate_xml_element_option(
&mut self,
option: &str,
object_name: &str,
attribute_name: &str,
) {
let option = option.trim();
if option.is_empty() {
self.add_error(ValidationError {
message: "XML option is not defined.".into(),
object: Some(object_name.to_string()),
attribute: Some(attribute_name.to_string()),
location: "Global".into(),
error_type: ErrorType::XMLError,
solution: Some(format!(
"Add an XML option to the property '{attribute_name}' using '- XML: <TAG_NAME>' in a sub-list below the property name."
)),
positions: vec![],
});
}
let options = option.split(',').map(|s| s.trim()).collect::<Vec<_>>();
for opt in options {
if let Err((e, solution)) = contains_special_characters(opt.trim(), false, true) {
self.add_error(ValidationError {
message: e,
object: Some(object_name.to_string()),
attribute: Some(attribute_name.to_string()),
location: "Global".into(),
error_type: ErrorType::XMLError,
solution: Some(format!("Resolve the issue by using '{solution}'.")),
positions: vec![],
});
}
}
}
fn validate_xml_wrapped_option(
&mut self,
option: &str,
object_name: &str,
attribute_name: &str,
wrapped: &Option<Vec<String>>,
) {
let option = option.trim();
if option.is_empty() {
self.add_error(ValidationError {
message: "XML option is not defined.".into(),
object: Some(object_name.to_string()),
attribute: Some(attribute_name.to_string()),
solution: Some(format!(
"Add an XML option to the property '{attribute_name}' using '- XML: <TAG_NAME>' in a sub-list below the property name."
)),
location: "Global".into(),
error_type: ErrorType::XMLError,
positions: vec![],
});
}
if let Some(wrapped_types) = wrapped {
if wrapped_types.len() > 2 {
self.add_error(ValidationError {
message: "XML wrapped option can only contain two types.".into(),
object: Some(object_name.to_string()),
attribute: Some(attribute_name.to_string()),
solution: Some("Reduce the depth of the wrapped option to two types and create a new object for the third type.".to_string()),
location: "Global".into(),
error_type: ErrorType::XMLError,
positions: vec![],
});
}
wrapped_types.iter().for_each(|wrapped_type| {
if let Err((e, solution)) = contains_special_characters(wrapped_type, true, true) {
self.add_error(ValidationError {
message: e,
object: Some(object_name.to_string()),
attribute: Some(attribute_name.to_string()),
solution: Some(format!("Resolve the issue by using '{solution}'.")),
location: "Global".into(),
error_type: ErrorType::XMLError,
positions: vec![],
});
}
});
}
let options = option.split(',').map(|s| s.trim()).collect::<Vec<_>>();
for opt in options {
if let Err((e, solution)) = contains_special_characters(opt.trim(), false, true) {
self.add_error(ValidationError {
message: e,
object: Some(object_name.to_string()),
attribute: Some(attribute_name.to_string()),
solution: Some(format!("Resolve the issue by using '{solution}'.")),
location: "Global".into(),
error_type: ErrorType::XMLError,
positions: vec![],
});
}
}
}
fn validate_xml_attribute_option(
&mut self,
option: &str,
object_name: &str,
attribute_name: &str,
) {
let option = option.trim();
if option.is_empty() {
self.add_error(ValidationError {
message: "XML attribute option is not defined.".into(),
object: Some(object_name.to_string()),
attribute: Some(attribute_name.to_string()),
solution: Some(format!(
"Add an XML option to the property '{attribute_name}' using '- XML: @<ATTRIBUTE_NAME>' in a sub-list below the property name."
)),
location: "Global".into(),
error_type: ErrorType::XMLError,
positions: vec![],
});
}
let options = option.split(',').map(|s| s.trim()).collect::<Vec<_>>();
for opt in options {
if let Err((e, solution)) = contains_special_characters(opt, false, true) {
self.add_error(ValidationError {
message: e,
object: Some(object_name.to_string()),
attribute: Some(attribute_name.to_string()),
solution: Some(format!("Resolve the issue by using '{solution}'.")),
location: "Global".into(),
error_type: ErrorType::XMLError,
positions: vec![],
});
}
}
}
fn extract_type_names(model: &DataModel) -> Vec<&str> {
let types = model
.objects
.iter()
.map(|object| object.name.as_str())
.chain(model.enums.iter().map(|enum_| enum_.name.as_str()))
.collect::<Vec<&str>>();
types
}
fn sort_errors(&mut self) {
self.errors.sort_by(|a, b| {
let line_a = a.positions.first().map(|pos| pos.line);
let line_b = b.positions.first().map(|pos| pos.line);
line_a.cmp(&line_b)
});
}
}
impl Default for Validator {
fn default() -> Self {
Self::new()
}
}
fn unique_elements<T: Eq + std::hash::Hash + Clone>(input: &[T]) -> Vec<T> {
let mut set = HashSet::new();
for item in input {
set.insert(item.clone());
}
set.into_iter().collect()
}
fn get_duplicates<'a>(collection: &'a [&'a str]) -> Vec<&'a str> {
let mut seen = HashSet::new();
let mut duplicates = HashSet::new();
for &item in collection {
if !seen.insert(item) {
duplicates.insert(item);
}
}
duplicates.into_iter().collect()
}
fn starts_with_character(name: &str) -> Result<(), (String, String)> {
match name.chars().next() {
Some(c) if c.is_alphabetic() => Ok(()),
_ => Err((
format!("Name '{name}' must start with a letter."),
name[1..].to_string(),
)),
}
}
fn contains_white_space(name: &str) -> Result<(), (String, String)> {
if name.contains(' ') {
Err((
format!(
"Name '{name}' contains whitespace, which is not valid. Use underscores instead."
),
name.replace(" ", "_").to_string(),
))
} else {
Ok(())
}
}
fn contains_special_characters(
name: &str,
allow_slash: bool,
allow_dash: bool,
) -> Result<(), (String, String)> {
if name.chars().any(|c| {
!c.is_alphanumeric()
&& c != '_'
&& c != ' '
&& (!allow_slash || c != '/')
&& (!allow_dash || c != '-')
}) {
Err((
format!("Name '{name}' contains special characters, which are not valid except for underscores."),
name.chars().filter(|c| c.is_alphanumeric() || *c == '_').collect::<String>().to_string(),
))
} else {
Ok(())
}
}
fn extract_object_positions(model: &DataModel) -> HashMap<String, Vec<Position>> {
let mut positions: HashMap<String, Vec<Position>> = HashMap::new();
for object in &model.objects {
if object.position.is_none() {
continue;
}
if let Some(pos) = positions.get_mut(&object.name) {
pos.push(object.position.unwrap());
} else {
positions.insert(object.name.clone(), vec![object.position.unwrap()]);
}
}
positions
}
fn extract_enum_positions(model: &DataModel) -> HashMap<String, Vec<Position>> {
let mut positions: HashMap<String, Vec<Position>> = HashMap::new();
for enum_ in &model.enums {
if enum_.position.is_none() {
continue;
}
if let Some(pos) = positions.get_mut(&enum_.name) {
pos.push(enum_.position.unwrap());
} else {
positions.insert(enum_.name.clone(), vec![enum_.position.unwrap()]);
}
}
positions
}
fn extract_attribute_positions(object: &Object) -> HashMap<String, Vec<Position>> {
let mut positions: HashMap<String, Vec<Position>> = HashMap::new();
for attribute in &object.attributes {
if attribute.position.is_none() {
continue;
}
if let Some(pos) = positions.get_mut(&attribute.name) {
pos.push(attribute.position.unwrap());
} else {
positions.insert(attribute.name.clone(), vec![attribute.position.unwrap()]);
}
}
positions
}
fn get_line_numbers(positions: &[Position]) -> String {
positions
.iter()
.map(|p| p.line.to_string())
.collect::<Vec<String>>()
.join(", ")
}
#[cfg(test)]
mod tests {
use crate::markdown::parser::parse_markdown;
#[test]
fn test_dashed_attribute_name_is_valid() {
let content = "### Configuration\n\n- coupling-scheme\n - Type: string\n";
let result = parse_markdown(content, None);
assert!(
result.is_ok(),
"dashed attribute names should pass validation, got: {:?}",
result.err()
);
}
#[test]
fn test_dashed_attribute_name_is_preserved() {
let content = "### Configuration\n\n- coupling-scheme\n - Type: string\n";
let model = parse_markdown(content, None).expect("model should parse");
let attr = &model.objects[0].attributes[0];
assert_eq!(attr.name, "coupling-scheme");
}
#[test]
fn test_dashed_object_name_is_invalid() {
let mut model = parse_markdown("### Configuration\n\n- name\n - Type: string\n", None)
.expect("model should parse");
model.objects[0].name = "coupling-scheme".to_string();
let mut validator = super::Validator::new();
validator.validate(&model);
assert!(!validator.is_valid);
assert!(validator
.errors
.iter()
.any(|e| e.error_type == super::ErrorType::NameError
&& e.object.as_deref() == Some("coupling-scheme")));
}
}