diff --git a/src/validation.rs b/src/validation.rs index 3ed70de..f0a730a 100644 --- a/src/validation.rs +++ b/src/validation.rs @@ -24,12 +24,13 @@ use crate::{ attribute::Attribute, datamodel::DataModel, + markdown::parser::Position, object::{Enumeration, Object}, }; use colored::Colorize; use log::error; use serde::Serialize; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::error::Error; use std::fmt::{Display, Formatter}; @@ -39,35 +40,52 @@ pub(crate) const BASIC_TYPES: [&str; 7] = [ ]; /// Represents a validation error in the data model. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, PartialEq)] pub struct ValidationError { pub message: String, pub object: Option, pub attribute: Option, pub location: String, pub error_type: ErrorType, + pub positions: Option>, } impl Display for ValidationError { /// Formats the validation error for display. fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let lines: Vec = self + .positions + .as_ref() + .unwrap() + .iter() + .map(|p| p.line.to_string()) + .collect(); + let mut line = lines.join(", "); + + if lines.len() > 0 { + line = format!("[line: {}]", line); + } else { + line = "".to_string(); + } + write!( f, - "[{}{}] {}: {}", + "{}[{}{}] {}: {}", + 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.message.red().bold(), )?; Ok(()) } } /// Enum representing the type of validation error. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, PartialEq)] pub enum ErrorType { NameError, TypeError, @@ -88,10 +106,14 @@ impl Display for ErrorType { } /// Validator for checking the integrity of a data model. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, PartialEq)] pub struct Validator { pub is_valid: bool, pub errors: Vec, + #[serde(skip_serializing)] + pub object_positions: HashMap>, + #[serde(skip_serializing)] + pub enum_positions: HashMap>, } impl Error for Validator {} @@ -112,11 +134,15 @@ impl Validator { 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(); } /// Adds a validation error to the validator. @@ -153,6 +179,10 @@ impl Validator { // If there are errors from a previous validation, reset the validator self.reset(); + // Extract the positions of all objects, enums, and attributes + self.object_positions = extract_object_positions(model); + self.enum_positions = extract_enum_positions(model); + // Extract the type names from the model let types = Self::extract_type_names(model); @@ -183,11 +213,12 @@ impl Validator { if !duplicates.is_empty() { for name in duplicates { self.add_error(ValidationError { - message: format!("Object {} is defined more than once.", name), + message: format!("Object '{}' is defined more than once.", name), object: Some(name.to_string()), attribute: None, location: "Global".into(), error_type: ErrorType::DuplicateError, + positions: self.object_positions.get(name).cloned(), }); } } @@ -216,11 +247,12 @@ impl Validator { if !duplicates.is_empty() { for name in duplicates { self.add_error(ValidationError { - message: format!("Enumeration {} is defined more than once.", name), + message: format!("Enumeration '{}' is defined more than once.", name), object: Some(name.to_string()), attribute: None, location: "Global".into(), error_type: ErrorType::DuplicateError, + positions: self.enum_positions.get(name).cloned(), }); } } @@ -239,7 +271,7 @@ impl Validator { // Validate the attributes of the object object.attributes.iter().for_each(|attribute| { - self.validate_attribute(attribute, types, &object.name); + self.validate_attribute(attribute, types, &object); }); } @@ -256,17 +288,20 @@ impl Validator { .map(|attribute| attribute.name.as_str()) .collect::>(); + 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 {} is defined more than once.", name), + message: format!("Property '{}' is defined more than once.", name), object: Some(object.name.clone()), attribute: Some(name.to_string()), location: "Global".into(), error_type: ErrorType::DuplicateError, + positions: attribute_positions.get(name).cloned(), }); } } @@ -280,11 +315,12 @@ impl Validator { 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), + message: format!("Type '{}' is empty and has no properties.", object.name), object: Some(object.name.clone()), attribute: None, location: "Global".into(), error_type: ErrorType::TypeError, + positions: self.object_positions.get(&object.name).cloned(), }); } } @@ -309,6 +345,7 @@ impl Validator { attribute: None, location: "Global".into(), error_type: ErrorType::NameError, + positions: self.object_positions.get(name).cloned(), }); } } @@ -327,6 +364,7 @@ impl Validator { attribute: None, location: "Global".into(), error_type: ErrorType::GlobalError, + positions: None, }); } } @@ -338,21 +376,24 @@ impl Validator { /// * `attribute` - A reference to the `Attribute` to be validated. /// * `types` - A slice of type names that are valid within the model. /// * `obj_name` - The name of the object that contains the attribute. - fn validate_attribute(&mut self, attribute: &Attribute, types: &[&str], obj_name: &str) { - self.validate_attribute_name(&attribute.name, obj_name); + 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(obj_name.into()), + 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, + positions: attribute_positions.get(&attribute.name).cloned(), }) } for dtype in &attribute.dtypes { - self.check_attr_dtype(attribute, types, obj_name, &dtype); + self.check_attr_dtype(attribute, types, object, &dtype); } } @@ -368,19 +409,22 @@ impl Validator { &mut self, attribute: &Attribute, types: &[&str], - obj_name: &str, - dtype: &&String, + object: &Object, + dtype: &str, ) { - if !types.contains(&dtype.as_str()) && !BASIC_TYPES.contains(&dtype.as_str()) { + let attribute_positions = extract_attribute_positions(object); + + if !types.contains(&dtype) && !BASIC_TYPES.contains(&dtype) { self.add_error(ValidationError { message: format!( - "Type {} of property {} not found. Either define the type or use a base type.", + "Type '{}' of property '{}' not found. Either define the type or use a base type.", dtype, attribute.name ), - object: Some(obj_name.into()), + object: Some(object.name.clone()), attribute: Some(attribute.name.clone()), location: "Global".into(), error_type: ErrorType::TypeError, + positions: attribute_positions.get(&attribute.name).cloned(), }) } } @@ -391,21 +435,24 @@ impl Validator { /// /// * `name` - The name of the attribute to be validated. /// * `obj_name` - The name of the object that contains the attribute. - fn validate_attribute_name(&mut self, name: &str, obj_name: &str) { + fn validate_attribute_name(&mut self, name: &str, object: &Object) { let checks = vec![ starts_with_character, contains_white_space, contains_special_characters, ]; + let attribute_positions = extract_attribute_positions(object); + for check in checks { if let Err(e) = check(name) { self.add_error(ValidationError { message: e, - object: Some(obj_name.into()), - attribute: Some(name.into()), + object: Some(object.name.clone()), + attribute: Some(name.to_string()), location: "Global".into(), error_type: ErrorType::NameError, + positions: attribute_positions.get(name).cloned(), }); } } @@ -535,3 +582,81 @@ fn contains_special_characters(name: &str) -> Result<(), String> { || Err(format!("Name '{}' contains special characters, which are not valid except for underscores.", name)) ).unwrap_or(Ok(())) } + +/// Extracts the positions of all objects in the data model. +/// +/// # Arguments +/// +/// * `model` - A reference to the `DataModel` to extract positions from. +/// +/// # Returns +/// +/// A `HashMap` mapping object names to their positions in the source code. +fn extract_object_positions(model: &DataModel) -> HashMap> { + let mut positions: HashMap> = 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.clone().unwrap()); + } else { + positions.insert(object.name.clone(), vec![object.position.clone().unwrap()]); + } + } + positions +} + +/// Extracts the positions of all enums in the data model. +/// +/// # Arguments +/// +/// * `model` - A reference to the `DataModel` to extract positions from. +/// +/// # Returns +/// +/// A `HashMap` mapping enum names to their positions in the source code. +fn extract_enum_positions(model: &DataModel) -> HashMap> { + let mut positions: HashMap> = 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.clone().unwrap()); + } else { + positions.insert(enum_.name.clone(), vec![enum_.position.clone().unwrap()]); + } + } + positions +} + +/// Extracts the positions of all attributes across all objects in the data model. +/// +/// # Arguments +/// +/// * `model` - A reference to the `DataModel` to extract positions from. +/// +/// # Returns +/// +/// A `HashMap` mapping attribute names to their positions in the source code. +fn extract_attribute_positions(object: &Object) -> HashMap> { + let mut positions: HashMap> = 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.clone().unwrap()); + } else { + positions.insert( + attribute.name.clone(), + vec![attribute.position.clone().unwrap()], + ); + } + } + positions +} diff --git a/tests/data/expected_invalid_complete.json b/tests/data/expected_invalid_complete.json new file mode 100644 index 0000000..e9a94dd --- /dev/null +++ b/tests/data/expected_invalid_complete.json @@ -0,0 +1,147 @@ +{ + "is_valid": false, + "errors": [ + { + "message": "Object 'Duplicate' is defined more than once.", + "object": "Duplicate", + "attribute": null, + "location": "Global", + "error_type": "DuplicateError", + "positions": [ + { + "line": 25, + "range": [ + 306, + 320 + ] + }, + { + "line": 30, + "range": [ + 347, + 361 + ] + } + ] + }, + { + "message": "Name '1number' must start with a letter.", + "object": "Test", + "attribute": "1number", + "location": "Global", + "error_type": "NameError", + "positions": [ + { + "line": 13, + "range": [ + 173, + 267 + ] + } + ] + }, + { + "message": "Name 'some name' contains whitespace, which is not valid. Use underscores instead.", + "object": "Test", + "attribute": "some name", + "location": "Global", + "error_type": "NameError", + "positions": [ + { + "line": 15, + "range": [ + 200, + 229 + ] + } + ] + }, + { + "message": "Type 'Undefined' of property 'undefined_type' not found. Either define the type or use a base type.", + "object": "Test", + "attribute": "undefined_type", + "location": "Global", + "error_type": "TypeError", + "positions": [ + { + "line": 17, + "range": [ + 229, + 267 + ] + } + ] + }, + { + "message": "Name '1Test' must start with a letter.", + "object": "1Test", + "attribute": null, + "location": "Global", + "error_type": "NameError", + "positions": [ + { + "line": 20, + "range": [ + 267, + 277 + ] + } + ] + }, + { + "message": "Name '1number' must start with a letter.", + "object": "1Test", + "attribute": "1number", + "location": "Global", + "error_type": "NameError", + "positions": [ + { + "line": 22, + "range": [ + 278, + 306 + ] + } + ] + }, + { + "message": "Property 'some_name' is defined more than once.", + "object": "DuplicateAttributes", + "attribute": "some_name", + "location": "Global", + "error_type": "DuplicateError", + "positions": [ + { + "line": 37, + "range": [ + 413, + 472 + ] + }, + { + "line": 39, + "range": [ + 442, + 472 + ] + } + ] + }, + { + "message": "Property 'some_name' has no type specified.", + "object": "NoType", + "attribute": "some_name", + "location": "Global", + "error_type": "TypeError", + "positions": [ + { + "line": 44, + "range": [ + 484, + 514 + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/data/model_invalid_complete.md b/tests/data/model_invalid_complete.md new file mode 100644 index 0000000..10ffb83 --- /dev/null +++ b/tests/data/model_invalid_complete.md @@ -0,0 +1,45 @@ +--- +id-field: true +repo: "https://www.github.com/my/repo/" +prefix: "tst" +prefixes: + schema: http://schema.org/ +nsmap: + tst: http://example.com/test/ +--- + +### Test Object + +- 1number + - Type: string +- some name + - Type: string +- undefined_type + - Type: Undefined + +### 1Test + +- 1number + - Type: string + +### Duplicate + +- value + - Type: string + +### Duplicate + +- value + - Type: string + +### DuplicateAttributes + +- some_name + - Type: string +- some_name + - Type: string + +### NoType + +- some_name + - DType: string diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index e00d67c..ebdc925 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -348,4 +348,22 @@ mod tests { } } } + + #[test] + fn test_invalid_complete() { + let path = Path::new("tests/data/model_invalid_complete.md"); + let model = DataModel::from_markdown(path); + + if let Err(e) = model { + let expected = std::fs::read_to_string("tests/data/expected_invalid_complete.json") + .expect("Could not read expected invalid complete"); + let expected: serde_json::Value = + serde_json::from_str(&expected).expect("Could not parse expected invalid complete"); + + let e: serde_json::Value = + serde_json::from_str(&serde_json::to_string_pretty(&e).unwrap()).unwrap(); + + assert_eq!(e, expected); + } + } }