From 86f41e49f87764f9afd4be1d0d008a320dcfc331 Mon Sep 17 00:00:00 2001 From: HampusM Date: Sun, 26 Mar 2023 18:50:31 +0200 Subject: feat: add deserializing API interface definitions --- src/api_interface_definition.rs | 773 +++++++++++++++++++++++++++++ src/command.rs | 35 +- src/deserialization/buffer_deserializer.rs | 40 +- src/deserialization/mod.rs | 3 + src/lib.rs | 56 ++- 5 files changed, 865 insertions(+), 42 deletions(-) create mode 100644 src/api_interface_definition.rs (limited to 'src') diff --git a/src/api_interface_definition.rs b/src/api_interface_definition.rs new file mode 100644 index 0000000..441d3f7 --- /dev/null +++ b/src/api_interface_definition.rs @@ -0,0 +1,773 @@ +//! GL API interface definition. + +use quick_xml::events::BytesStart; + +use crate::deserialization::{Deserialize, Deserializer, DeserializerError}; +use crate::DeserializationError; + +/// GL API interface definition. +#[derive(Debug, PartialEq, Eq)] +pub struct APIInterfaceDefinition +{ + api_name: String, + name: String, + version: String, + requirements: Vec, + removals: Vec, +} + +impl APIInterfaceDefinition +{ + /// Returns a new `APIInterfaceDefinition`. + #[must_use] + pub fn new( + api_name: impl Into, + name: impl Into, + version: impl Into, + requirements: impl IntoIterator, + removals: impl IntoIterator, + ) -> Self + { + Self { + api_name: api_name.into(), + name: name.into(), + version: version.into(), + requirements: requirements.into_iter().collect(), + removals: removals.into_iter().collect(), + } + } + + /// Returns the API name. + #[must_use] + pub fn api_name(&self) -> &str + { + &self.api_name + } + + /// Returns the name. + #[must_use] + pub fn name(&self) -> &str + { + &self.name + } + + /// Returns the version. + #[must_use] + pub fn version(&self) -> &str + { + &self.version + } + + /// Returns the requirements. + #[must_use] + pub fn requirements(&self) -> &[Requirement] + { + &self.requirements + } + + /// Returns the removals. + #[must_use] + pub fn removals(&self) -> &[Removal] + { + &self.removals + } +} + +impl Deserialize for APIInterfaceDefinition +{ + type Error = Error; + + fn deserialize( + start: &BytesStart, + deserializer: &mut TDeserializer, + ) -> Result + { + let api_name = String::from_utf8( + start + .try_get_attribute(b"api") + .map_err(DeserializerError::ReadFailed)? + .ok_or_else(|| Error::MissingAPIName)? + .value + .into_owned(), + ) + .map_err(|_| Error::APINameNotUTF8)?; + + let name = String::from_utf8( + start + .try_get_attribute(b"name") + .map_err(DeserializerError::ReadFailed)? + .ok_or_else(|| Error::MissingName)? + .value + .into_owned(), + ) + .map_err(|_| Error::NameNotUTF8)?; + + let version = String::from_utf8( + start + .try_get_attribute(b"number") + .map_err(DeserializerError::ReadFailed)? + .ok_or_else(|| Error::MissingVersionNumber)? + .value + .into_owned(), + ) + .map_err(|_| Error::VersionNotUTF8)?; + + let requirements = deserializer.de_tag_list::("require")?; + + let removals = deserializer.de_tag_list::("remove")?; + + Ok(Self { + api_name, + name, + version, + requirements, + removals, + }) + } +} + +/// [`APIInterfaceDefinition`] error. +#[derive(Debug, thiserror::Error)] +pub enum Error +{ + /// Missing API name attribute. + #[error("Missing API name attribute")] + MissingAPIName, + + /// Missing name attribute. + #[error("Missing name attribute")] + MissingName, + + /// Missing version number attribute. + #[error("Missing version number attribute")] + MissingVersionNumber, + + /// API name is not valid UTF-8. + #[error("API name is not valid UTF-8")] + APINameNotUTF8, + + /// Name is not valid UTF-8. + #[error("Name is not valid UTF-8")] + NameNotUTF8, + + /// Version is not valid UTF-8. + #[error("Version is not valid UTF-8")] + VersionNotUTF8, + + /// Deserialization failed. + #[error("Deserialization failed")] + DeserializationFailed(#[from] DeserializationError), +} + +impl From for Error +{ + fn from(err: DeserializerError) -> Self + { + DeserializationError(err).into() + } +} + +/// GL API feature requirement. +#[derive(Debug, PartialEq, Eq)] +pub struct Requirement +{ + profile: Option, + comment: Option, + features: Vec, +} + +impl Requirement +{ + /// Returns a new `Requirement`. + #[must_use] + pub fn new( + profile: Option, + comment: Option, + features: impl IntoIterator, + ) -> Self + { + Self { + profile, + comment, + features: features.into_iter().collect(), + } + } + + /// Returns the required features. + #[must_use] + pub fn features(&self) -> &[Feature] + { + &self.features + } + + /// Returns the profile. + #[must_use] + pub fn profile(&self) -> Option<&str> + { + self.profile.as_deref() + } + + /// Returns the comment. + #[must_use] + pub fn comment(&self) -> Option<&str> + { + self.comment.as_deref() + } +} + +impl Deserialize for Requirement +{ + type Error = RequirementError; + + fn deserialize( + start: &BytesStart, + deserializer: &mut TDeserializer, + ) -> Result + { + let profile = match start + .try_get_attribute(b"profile") + .map_err(DeserializerError::ReadFailed)? + { + Some(comment_attr) => Some( + String::from_utf8(comment_attr.value.into_owned()) + .map_err(|_| RequirementError::ProfileNotUTF8)?, + ), + None => None, + }; + + let comment = match start + .try_get_attribute(b"comment") + .map_err(DeserializerError::ReadFailed)? + { + Some(comment_attr) => Some( + String::from_utf8(comment_attr.value.into_owned()) + .map_err(|_| RequirementError::CommentNotUTF8)?, + ), + None => None, + }; + + let features = deserializer.de_list::()?; + + Ok(Self { + profile, + comment, + features, + }) + } +} + +/// [`Requirement`] error. +#[derive(Debug, thiserror::Error)] +pub enum RequirementError +{ + /// Profile is not valid UTF-8. + #[error("Profile is not valid UTF-8")] + ProfileNotUTF8, + + /// Comment is not valid UTF-8. + #[error("Comment is not valid UTF-8")] + CommentNotUTF8, + + /// Deserialization failed. + #[error("Deserialization failed")] + DeserializationFailed(#[from] DeserializationError), +} + +impl From for RequirementError +{ + fn from(err: DeserializerError) -> Self + { + DeserializationError(err).into() + } +} + +/// GL API feature removal. +#[derive(Debug, PartialEq, Eq)] +pub struct Removal +{ + profile: String, + comment: String, + features: Vec, +} + +impl Removal +{ + /// Returns a new `Removal`. + #[must_use] + pub fn new( + profile: impl Into, + comment: impl Into, + features: impl IntoIterator, + ) -> Self + { + Self { + profile: profile.into(), + comment: comment.into(), + features: features.into_iter().collect(), + } + } + + /// Returns the removed features. + #[must_use] + pub fn features(&self) -> &[Feature] + { + &self.features + } + + /// Returns the profile. + #[must_use] + pub fn profile(&self) -> &str + { + &self.profile + } + + /// Returns the comment. + #[must_use] + pub fn comment(&self) -> &str + { + &self.comment + } +} + +impl Deserialize for Removal +{ + type Error = RemovalError; + + fn deserialize( + start: &BytesStart, + deserializer: &mut TDeserializer, + ) -> Result + { + let profile = String::from_utf8( + start + .try_get_attribute(b"profile") + .map_err(DeserializerError::ReadFailed)? + .ok_or_else(|| RemovalError::MissingProfile)? + .value + .into_owned(), + ) + .map_err(|_| RemovalError::ProfileNotUTF8)?; + + let comment = String::from_utf8( + start + .try_get_attribute(b"comment") + .map_err(DeserializerError::ReadFailed)? + .ok_or_else(|| RemovalError::MissingComment)? + .value + .into_owned(), + ) + .map_err(|_| RemovalError::CommentNotUTF8)?; + + let features = deserializer.de_list::()?; + + Ok(Self { + profile, + comment, + features, + }) + } +} + +/// [`Removal`] error. +#[derive(Debug, thiserror::Error)] +pub enum RemovalError +{ + /// Missing profile attribute. + #[error("Missing profile attribute")] + MissingProfile, + + /// Missing comment attribute. + #[error("Missing comment attribute")] + MissingComment, + + /// Profile is not valid UTF-8. + #[error("Profile is not valid UTF-8")] + ProfileNotUTF8, + + /// Comment is not valid UTF-8. + #[error("Comment is not valid UTF-8")] + CommentNotUTF8, + + /// Deserialization failed. + #[error("Deserialization failed")] + DeserializationFailed(#[from] DeserializationError), +} + +impl From for RemovalError +{ + fn from(err: DeserializerError) -> Self + { + DeserializationError(err).into() + } +} + +/// GL API feature. +#[derive(Debug, PartialEq, Eq)] +pub struct Feature +{ + kind: FeatureKind, + name: String, + comment: Option, +} + +impl Feature +{ + /// Returns a new `Feature`. + #[must_use] + pub fn new( + kind: FeatureKind, + name: impl Into, + comment: Option, + ) -> Self + { + Self { + kind, + name: name.into(), + comment, + } + } + + /// Returns the feature kind. + #[must_use] + pub fn kind(&self) -> FeatureKind + { + self.kind + } + + /// Returns the feature name. + #[must_use] + pub fn name(&self) -> &str + { + &self.name + } + + /// Returns the feature comment. + #[must_use] + pub fn comment(&self) -> Option<&str> + { + self.comment.as_deref() + } +} + +impl Deserialize for Feature +{ + type Error = FeatureError; + + fn deserialize( + start: &BytesStart, + _deserializer: &mut TDeserializer, + ) -> Result + { + let kind = match start.name().as_ref() { + b"enum" => Ok(FeatureKind::Enum), + b"command" => Ok(FeatureKind::Command), + b"type" => Ok(FeatureKind::Type), + unknown_kind => Err(FeatureError::UnknownFeatureKind( + String::from_utf8_lossy(unknown_kind).into_owned(), + )), + }?; + + let name = String::from_utf8( + start + .try_get_attribute(b"name") + .map_err(DeserializerError::ReadFailed)? + .ok_or_else(|| FeatureError::MissingName)? + .value + .into_owned(), + ) + .map_err(|_| FeatureError::NameNotUTF8)?; + + let comment = match start + .try_get_attribute(b"comment") + .map_err(DeserializerError::ReadFailed)? + { + Some(comment_attr) => Some( + String::from_utf8(comment_attr.value.into_owned()) + .map_err(|_| FeatureError::CommentNotUTF8)?, + ), + None => None, + }; + + Ok(Self { + kind, + name, + comment, + }) + } +} + +/// [`Feature`] error. +#[derive(Debug, thiserror::Error)] +pub enum FeatureError +{ + /// Unknown feature kind. + #[error("Unknown feature kind {0}")] + UnknownFeatureKind(String), + + /// Missing name attribute. + #[error("Missing name attribute")] + MissingName, + + /// Name is not valid UTF-8. + #[error("Name is not valid UTF-8")] + NameNotUTF8, + + /// Comment is not valid UTF-8. + #[error("Comment is not valid UTF-8")] + CommentNotUTF8, + + /// Deserialization failed. + #[error("Deserialization failed")] + DeserializationFailed(#[from] DeserializationError), +} + +impl From for FeatureError +{ + fn from(err: DeserializerError) -> Self + { + DeserializationError(err).into() + } +} + +/// GL API feature kind. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FeatureKind +{ + /// A enum. + Enum, + + /// A command. + Command, + + /// A type. + Type, +} + +#[cfg(test)] +mod tests +{ + use pretty_assertions::{assert_eq, assert_str_eq}; + + use super::*; + use crate::deserialization::MockDeserializer; + + #[test] + fn deserialize_feature_works() + { + let mut mock_deserializer = MockDeserializer::new(); + + let commentless_enum_feature = Feature::deserialize( + &BytesStart::new("enum").with_attributes([("name", "GL_BLEND")]), + &mut mock_deserializer, + ) + .expect("Expected Ok"); + + assert_eq!(commentless_enum_feature.kind, FeatureKind::Enum); + assert_str_eq!(commentless_enum_feature.name, "GL_BLEND"); + assert!(commentless_enum_feature.comment.is_none()); + + let enum_feature = Feature::deserialize( + &BytesStart::new("enum").with_attributes([ + ("name", "GL_VERTEX_BINDING_BUFFER"), + ("comment", "Added in 2013/10/22 update to the spec"), + ]), + &mut mock_deserializer, + ) + .expect("Expected Ok"); + + assert_eq!(enum_feature.kind, FeatureKind::Enum); + assert_str_eq!(enum_feature.name, "GL_VERTEX_BINDING_BUFFER"); + + assert_str_eq!( + enum_feature.comment.expect("Expected Some"), + "Added in 2013/10/22 update to the spec" + ); + + let command_feature = Feature::deserialize( + &BytesStart::new("command").with_attributes([("name", "glCreateBuffers")]), + &mut mock_deserializer, + ) + .expect("Expected Ok"); + + assert_eq!(command_feature.kind, FeatureKind::Command); + assert_str_eq!(command_feature.name, "glCreateBuffers"); + assert!(command_feature.comment.is_none()); + + let type_feature = Feature::deserialize( + &BytesStart::new("type").with_attributes([("name", "GLbyte")]), + &mut mock_deserializer, + ) + .expect("Expected Ok"); + + assert_eq!(type_feature.kind, FeatureKind::Type); + assert_str_eq!(type_feature.name, "GLbyte"); + assert!(type_feature.comment.is_none()); + + assert!(matches!( + Feature::deserialize(&BytesStart::new("foo"), &mut mock_deserializer), + Err(FeatureError::UnknownFeatureKind(_)) + )); + } + + #[test] + fn deserialize_removal_works() + { + let mut mock_deserializer = MockDeserializer::new(); + + mock_deserializer.expect_de_list().times(1).returning(|_| { + Ok(vec![ + Feature::new(FeatureKind::Command, "glNewList", None), + Feature::new(FeatureKind::Command, "glEndList", None), + ]) + }); + + let removal = Removal::deserialize( + &BytesStart::new("remove").with_attributes([ + ("profile", "core"), + ( + "comment", + "Compatibility-only GL 1.0 features removed from GL 3.2", + ), + ]), + &mut mock_deserializer, + ) + .expect("Expected Ok"); + + assert_str_eq!(removal.profile, "core"); + + assert_str_eq!( + removal.comment, + "Compatibility-only GL 1.0 features removed from GL 3.2" + ); + + assert_eq!(removal.features.len(), 2); + } + + #[test] + fn deserialize_requirement_works() + { + let mut mock_deserializer = MockDeserializer::new(); + + mock_deserializer.expect_de_list().times(1).returning(|_| { + Ok(vec![ + Feature::new(FeatureKind::Command, "glNewList", None), + Feature::new(FeatureKind::Command, "glEndList", None), + ]) + }); + + mock_deserializer.expect_de_list().times(1).returning(|_| { + Ok(vec![ + Feature::new(FeatureKind::Enum, "GL_COLOR_TABLE", None), + Feature::new(FeatureKind::Enum, "GL_MINMAX", None), + Feature::new(FeatureKind::Enum, "GL_HISTOGRAM", None), + ]) + }); + + mock_deserializer.expect_de_list().times(1).returning(|_| { + Ok(vec![ + Feature::new(FeatureKind::Enum, "GL_STACK_UNDERFLOW", None), + Feature::new(FeatureKind::Enum, "GL_STACK_OVERFLOW", None), + ]) + }); + + let requirement = + Requirement::deserialize(&BytesStart::new("require"), &mut mock_deserializer) + .expect("Expected Ok"); + + assert!(requirement.profile.is_none()); + assert!(requirement.comment.is_none()); + + assert_eq!(requirement.features.len(), 2); + + let requirement = Requirement::deserialize( + &BytesStart::new("require").with_attributes([("profile", "compatibility")]), + &mut mock_deserializer, + ) + .expect("Expected Ok"); + + assert_str_eq!(requirement.profile.expect("Expected Some"), "compatibility"); + assert!(requirement.comment.is_none()); + + assert_eq!(requirement.features.len(), 3); + + let requirement = Requirement::deserialize( + &BytesStart::new("require").with_attributes([ + ("profile", "core"), + ( + "comment", + concat!( + "Restore functionality removed in GL 3.2 core ", + "to GL 4.3. Needed for debug interface." + ), + ), + ]), + &mut mock_deserializer, + ) + .expect("Expected Ok"); + + assert_str_eq!(requirement.profile.expect("Expected Some"), "core"); + assert_str_eq!( + requirement.comment.expect("Expected Some"), + concat!( + "Restore functionality removed in GL 3.2 core to ", + "GL 4.3. Needed for debug interface." + ) + ); + + assert_eq!(requirement.features.len(), 2); + } + + #[test] + fn deserialize_api_interface_definition_works() + { + let mut mock_deserializer = MockDeserializer::new(); + + mock_deserializer + .expect_de_tag_list() + .times(1) + .returning(|_, _| { + Ok(vec![ + Requirement::new( + None, + None, + vec![ + Feature::new(FeatureKind::Command, "glFenceSync", None), + Feature::new(FeatureKind::Enum, "GL_WAIT_FAILED", None), + ], + ), + Requirement::new( + None, + Some("Reuse ARB_sync".to_string()), + [Feature::new(FeatureKind::Enum, "GL_OBJECT_TYPE", None)], + ), + ]) + }); + + mock_deserializer + .expect_de_tag_list() + .times(1) + .returning(|_, _| { + Ok(vec![Removal::new( + "core", + "Compatibility-only GL 1.0 features removed from GL 3.2", + vec![Feature::new(FeatureKind::Command, "glBegin", None)], + )]) + }); + + let api_interface_definition = APIInterfaceDefinition::deserialize( + &BytesStart::new("feature").with_attributes([ + ("api", "gl"), + ("name", "GL_VERSION_3_2"), + ("number", "3.2"), + ]), + &mut mock_deserializer, + ) + .expect("Expected Ok"); + + assert_str_eq!(api_interface_definition.api_name, "gl"); + assert_str_eq!(api_interface_definition.name, "GL_VERSION_3_2"); + assert_str_eq!(api_interface_definition.version, "3.2"); + assert_eq!(api_interface_definition.requirements.len(), 2); + assert_eq!(api_interface_definition.removals.len(), 1); + } +} diff --git a/src/command.rs b/src/command.rs index 5d3c935..fd449aa 100644 --- a/src/command.rs +++ b/src/command.rs @@ -310,43 +310,10 @@ mod tests { use pretty_assertions::assert_str_eq; use quick_xml::events::Event; - use ridicule::mock; use ridicule::predicate::{always, eq, function}; use super::*; - - mock! { - MockDeserializer {} - - impl Deserializer for MockDeserializer { - fn de_tag( - &mut self, - tag_name: &str, - ignore_end: IgnoreEnd, - ) -> Result; - - fn de_tag_with( - &mut self, - tag_name: &str, - ignore_end: IgnoreEnd, - deserialize: DeserializeFn, - ) -> Result - where - Err: std::error::Error + Send + Sync + 'static, - DeserializeFn: FnOnce(&BytesStart, &mut MockDeserializer) -> Result; - - fn de_tag_list( - &mut self, - tag_name: &str - ) -> Result, DeserializerError>; - - fn de_text(&mut self) -> Result; - - fn skip_to_tag_start(&mut self, tag_name: &str) -> Result<(), DeserializerError>; - - fn skip_to_tag_end(&mut self, tag_name: &str) -> Result<(), DeserializerError>; - } - } + use crate::deserialization::MockDeserializer; #[test] fn deserialize_prototype_works_with_ptype() diff --git a/src/deserialization/buffer_deserializer.rs b/src/deserialization/buffer_deserializer.rs index 652e1ff..a4e7b2f 100644 --- a/src/deserialization/buffer_deserializer.rs +++ b/src/deserialization/buffer_deserializer.rs @@ -98,7 +98,7 @@ where }; if let IgnoreEnd::No = ignore_end { - self.read_end_event(tag_name)?; + self.read_end_event(tag_name.as_bytes())?; } Ok(deserialized) @@ -132,7 +132,38 @@ where ) })?; - self.read_end_event(tag_name)?; + self.read_end_event(tag_name.as_bytes())?; + + deserialized_items.push(deserialized); + } + + Ok(deserialized_items) + } + + fn de_list(&mut self) -> Result, DeserializerError> + { + let mut deserialized_items = Vec::new(); + + loop { + let start = match read_event!(self) { + Event::Start(start) => start, + Event::Comment(_) => { + continue; + } + event => { + self.leftover_event = Some(event.into_owned()); + break; + } + }; + + let deserialized = De::deserialize(&start, self).map_err(|err| { + DeserializerError::DeserializeFailed( + type_name::(), + WrappedDeserializeError::new(err), + ) + })?; + + self.read_end_event(start.name().as_ref())?; deserialized_items.push(deserialized); } @@ -193,12 +224,11 @@ impl BufferDeserializer where Source: BufRead, { - fn read_end_event(&mut self, tag_name: &str) -> Result<(), DeserializerError> + fn read_end_event(&mut self, tag_name: &[u8]) -> Result<(), DeserializerError> { let event = read_event!(self); - if matches!(&event, Event::End(end) if end.name().as_ref() == tag_name.as_bytes()) - { + if matches!(&event, Event::End(end) if end.name().as_ref() == tag_name) { return Ok(()); } diff --git a/src/deserialization/mod.rs b/src/deserialization/mod.rs index fa25e4b..b86c2af 100644 --- a/src/deserialization/mod.rs +++ b/src/deserialization/mod.rs @@ -15,6 +15,7 @@ pub trait Deserialize: Sized ) -> Result; } +#[cfg_attr(test, ridicule::automock)] pub trait Deserializer { fn de_tag( @@ -38,6 +39,8 @@ pub trait Deserializer tag_name: &str, ) -> Result, DeserializerError>; + fn de_list(&mut self) -> Result, DeserializerError>; + fn de_text(&mut self) -> Result; fn skip_to_tag_start(&mut self, tag_name: &str) -> Result<(), DeserializerError>; diff --git a/src/lib.rs b/src/lib.rs index 68af8ae..4e33f8c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -26,10 +26,12 @@ use std::io::Read; use quick_xml::events::BytesStart; +use crate::api_interface_definition::APIInterfaceDefinition; use crate::command::{Command, Error as CommandError}; use crate::deserialization::buffer_deserializer::BufferDeserializer; use crate::deserialization::{Deserialize, Deserializer, DeserializerError, IgnoreEnd}; +pub mod api_interface_definition; pub mod command; mod deserialization; @@ -45,6 +47,7 @@ const REGISTRY_TAG_NAME: &str = "registry"; pub struct Registry { commands: Vec, + api_interface_definitions: Vec, } impl Registry @@ -96,19 +99,30 @@ impl Registry /// # Note /// This will **NOT** use anything from the actual OpenGL registry. Use the /// [`Registry::retrieve`] method for that. - pub fn new(commands: impl IntoIterator) -> Self + pub fn new( + commands: impl IntoIterator, + api_interface_definitions: impl IntoIterator, + ) -> Self { Self { commands: commands.into_iter().collect(), + api_interface_definitions: api_interface_definitions.into_iter().collect(), } } - /// Returns the available commands. + /// Returns the commands. #[must_use] pub fn commands(&self) -> &[Command] { &self.commands } + + /// Returns the API interface definitions. + #[must_use] + pub fn api_interface_definitions(&self) -> &[APIInterfaceDefinition] + { + &self.api_interface_definitions + } } impl Deserialize for Registry @@ -127,7 +141,15 @@ impl Deserialize for Registry deserializer.de_tag_list::("command") })?; - Ok(Self { commands }) + deserializer.skip_to_tag_start("feature")?; + + let api_interface_definitions = + deserializer.de_tag_list::("feature")?; + + Ok(Self { + commands, + api_interface_definitions, + }) } } @@ -168,3 +190,31 @@ impl From for RegistryError #[derive(Debug, thiserror::Error)] #[error(transparent)] pub struct DeserializationError(#[from] DeserializerError); + +#[cfg(test)] +mod tests +{ + use super::*; + + #[test] + fn registry_works() + { + let registry = Registry::retrieve().expect("Expected Ok"); + + for api_interface_def in registry.api_interface_definitions() { + println!( + "{} - {}", + api_interface_def.api_name(), + api_interface_def.version() + ); + + println!("Removals:"); + + for removal in api_interface_def.removals() { + for feature in removal.features() { + println!(" {:?} - {}", feature.kind(), feature.name()); + } + } + } + } +} -- cgit v1.2.3-18-g5258