diff --git a/Cargo.toml b/Cargo.toml index 99eca0c..9b0c983 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ thiserror = "1.0.50" [dev-dependencies] codspeed-criterion-compat = "2.3.3" criterion = { version = "0.5.1", features = ["html_reports"] } +proptest = "1.4.0" [[bench]] name = "parse_as3257" diff --git a/examples/print_parsed_as3257.rs b/examples/print_parsed_as3257.rs index c162144..9ab9c38 100644 --- a/examples/print_parsed_as3257.rs +++ b/examples/print_parsed_as3257.rs @@ -1,4 +1,9 @@ -use rpsl_parser::{parse_object, Object}; +use rpsl_parser::{parse_object, ObjectView}; + +fn main() { + let aut_num_gtt: ObjectView = parse_object(AS3257).unwrap(); + println!("{:#?}", aut_num_gtt); +} const AS3257: &str = r#"aut-num: AS3257 as-name: GTT-BACKBONE @@ -9569,8 +9574,3 @@ last-modified: 2023-07-21T10:03:34Z source: RIPE "#; - -fn main() { - let aut_num_gtt: Object = parse_object(AS3257).unwrap(); - println!("{:#?}", aut_num_gtt); -} diff --git a/examples/print_parsed_as3257_whois_response.rs b/examples/print_parsed_as3257_whois_response.rs index da0d536..8a4b6c7 100644 --- a/examples/print_parsed_as3257_whois_response.rs +++ b/examples/print_parsed_as3257_whois_response.rs @@ -1,4 +1,9 @@ -use rpsl_parser::{parse_whois_response, Object}; +use rpsl_parser::{parse_whois_response, ObjectView}; + +fn main() { + let parsed_objects: Vec = parse_whois_response(AS3257_WHOIS_RESPONSE).unwrap(); + println!("{:#?}", parsed_objects); +} const AS3257_WHOIS_RESPONSE: &str = r#" % Note: this output has been filtered. @@ -9699,8 +9704,3 @@ source: RIPE # Filtered "#; - -fn main() { - let parsed_objects: Vec = parse_whois_response(AS3257_WHOIS_RESPONSE).unwrap(); - println!("{:#?}", parsed_objects); -} diff --git a/src/lib.rs b/src/lib.rs index fc339b6..c039e75 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,7 +6,7 @@ pub use parser::{parse_object, parse_whois_response}; pub use rpsl::error::AttributeError; -pub use rpsl::{Attribute, Object}; +pub use rpsl::{Attribute, AttributeView, Object, ObjectView}; mod parser; mod rpsl; diff --git a/src/parser/component.rs b/src/parser/component.rs index 3a0ceba..9a69045 100644 --- a/src/parser/component.rs +++ b/src/parser/component.rs @@ -1,4 +1,4 @@ -use crate::rpsl::Attribute; +use crate::rpsl::AttributeView; use nom::{ bytes::complete::{tag, take_while}, character::complete::{newline, space0}, @@ -24,7 +24,7 @@ pub fn server_message(input: &str) -> IResult<&str, &str> { // A RPSL attribute consisting of a name and one or more values. // The name is followed by a colon and optional spaces. // Single value attributes are limited to one line, while multi value attributes span over multiple lines. -pub fn attribute(input: &str) -> IResult<&str, Attribute> { +pub fn attribute(input: &str) -> IResult<&str, AttributeView> { let (remaining, (name, first_value)) = separated_pair( terminated(subcomponent::attribute_name, tag(":")), space0, @@ -32,13 +32,13 @@ pub fn attribute(input: &str) -> IResult<&str, Attribute> { )(input)?; if peek(subcomponent::continuation_char)(remaining).is_err() { - Ok((remaining, Attribute::new(name, first_value).unwrap())) + Ok((remaining, AttributeView::new_single(name, first_value))) } else { let (remaining, continuation_values) = many0(subcomponent::continuation_line)(remaining)?; - let mut values: Vec<&str> = Vec::with_capacity(1 + continuation_values.len()); - values.push(first_value); - values.extend(continuation_values); - Ok((remaining, Attribute::new(name, values).unwrap())) + let values = std::iter::once(first_value) + .chain(continuation_values) + .collect(); + Ok((remaining, AttributeView::new_multi(name, values))) } } @@ -78,7 +78,7 @@ mod tests { attribute("import: from AS12 accept AS12\n"), Ok(( "", - Attribute::new("import", "from AS12 accept AS12").unwrap() + AttributeView::new_single("import", "from AS12 accept AS12") )) ); } @@ -94,7 +94,7 @@ mod tests { )), Ok(( "remarks: Peering Policy\n", - Attribute::new( + AttributeView::new_multi( "remarks", vec![ "Locations", @@ -102,7 +102,6 @@ mod tests { "NY1 - Equinix New York, Newark", ] ) - .unwrap() )) ); } diff --git a/src/parser/main.rs b/src/parser/main.rs index af83c28..c981110 100644 --- a/src/parser/main.rs +++ b/src/parser/main.rs @@ -1,5 +1,5 @@ use super::component; -use crate::rpsl::Object; +use crate::rpsl::ObjectView; use nom::{ branch::alt, bytes::complete::tag, @@ -15,13 +15,13 @@ use nom::{ /// /// As per [RFC 2622](https://datatracker.ietf.org/doc/html/rfc2622#section-2), an RPSL object /// is textually represented as a list of attribute-value pairs that ends when a blank line is encountered. -fn object_block(input: &str) -> IResult<&str, Object> { +fn object_block(input: &str) -> IResult<&str, ObjectView> { let (remaining, attributes) = terminated(many1(component::attribute), newline)(input)?; - Ok((remaining, attributes.into())) + Ok((remaining, ObjectView::new(attributes, Some(input)))) } /// Uses the object block parser but allows for optional padding with server messages or newlines. -fn padded_object_block(input: &str) -> IResult<&str, Object> { +fn padded_object_block(input: &str) -> IResult<&str, ObjectView> { let (remaining, object) = delimited( optional_message_or_newlines, object_block, @@ -37,30 +37,26 @@ fn optional_message_or_newlines(input: &str) -> IResult<&str, Vec<&str>> { Ok((remaining, message_or_newlines)) } -/// Parse an RPSL object from it's textual representation. +/// Parse RPSL into an [`ObjectView`], a type that borrows from the RPSL input and provides +/// a convenient interface to access attributes as references. /// /// ```text -/// as-set: as-nflx -/// descr: Netflix AS numbers -/// members: AS40027 -/// members: AS2906 -/// members: AS55095 -/// mnt-by: MAINT-AS40027 -/// changed: rwoolley@netflix.com 20210226 -/// source: RADB +/// role: ACME Company +/// address: Packet Street 6 +/// address: 128 Series of Tubes +/// address: Internet +/// email: rpsl-parser@github.com +/// nic-hdl: RPSL1-RIPE +/// source: RIPE /// ↓ -/// ┌───────────────────────────────────────────────┐ -/// │ Object │ -/// ├───────────────────────────────────────────────┤ -/// │ [as-set] ─── as-nflx │ -/// │ [descr] ─── Netflix AS numbers │ -/// │ [members] ──┬─ AS40027 │ -/// │ ├─ AS2906 │ -/// │ └─ AS55095 │ -/// │ [mnt-by] ─── MAINT-AS40027 │ -/// │ [changed] ─── rwoolley@netflix.com 20210226 │ -/// │ [source] ─── RADB │ -/// └───────────────────────────────────────────────┘ +/// role: ACME Company ◀─────────────── &"role" ─── &"ACME Company" +/// address: Packet Street 6 ◀──────────── &"address" ─┬─ &"Packet Street 6" +/// address: 128 Series of Tubes ◀──────── &"address" ─┬─ &"128 Series of Tubes" +/// address: Internet ◀─────────────────── &"address" ─┬─ &"Internet" +/// email: rpsl-parser@github.com ◀───── &"email" ─── &"rpsl-parser@github.com" +/// nic-hdl: RPSL1-RIPE ◀───────────────── &"nic-hdl" ─── &"RPSL1-RIPE" +/// source: RIPE ◀─────────────────────── &"source" ─── &"RIPE" + /// ``` /// /// # Errors @@ -68,7 +64,7 @@ fn optional_message_or_newlines(input: &str) -> IResult<&str, Vec<&str>> { /// /// # Examples /// ``` -/// # use rpsl_parser::{parse_object, Attribute, Object}; +/// # use rpsl_parser::{parse_object, object}; /// # fn main() -> Result<(), Box> { /// let role_acme = " /// role: ACME Company @@ -80,18 +76,18 @@ fn optional_message_or_newlines(input: &str) -> IResult<&str, Vec<&str>> { /// source: RIPE /// /// "; -/// let object = parse_object(role_acme)?; +/// let parsed = parse_object(role_acme)?; /// assert_eq!( -/// object, -/// Object::new(vec![ -/// Attribute::new("role", "ACME Company")?, -/// Attribute::new("address", "Packet Street 6")?, -/// Attribute::new("address", "128 Series of Tubes")?, -/// Attribute::new("address", "Internet")?, -/// Attribute::new("email", "rpsl-parser@github.com")?, -/// Attribute::new("nic-hdl", "RPSL1-RIPE")?, -/// Attribute::new("source", "RIPE")?, -/// ]) +/// parsed, +/// object! { +/// "role": "ACME Company"; +/// "address": "Packet Street 6"; +/// "address": "128 Series of Tubes"; +/// "address": "Internet"; +/// "email": "rpsl-parser@github.com"; +/// "nic-hdl": "RPSL1-RIPE"; +/// "source": "RIPE"; +/// } /// ); /// # Ok(()) /// # } @@ -99,7 +95,7 @@ fn optional_message_or_newlines(input: &str) -> IResult<&str, Vec<&str>> { /// /// Values spread over multiple lines can be parsed too. /// ``` -/// # use rpsl_parser::{parse_object, Attribute, Object}; +/// # use rpsl_parser::{parse_object, object}; /// # fn main() -> Result<(), Box> { /// let multiline_remark = " /// remarks: Value 1 @@ -108,9 +104,9 @@ fn optional_message_or_newlines(input: &str) -> IResult<&str, Vec<&str>> { /// "; /// assert_eq!( /// parse_object(multiline_remark)?, -/// Object::new(vec![ -/// Attribute::new("remarks", vec!["Value 1", "Value 2"])? -/// ]) +/// object! { +/// "remarks": "Value 1", "Value 2"; +/// } /// ); /// # Ok(()) /// # } @@ -118,7 +114,7 @@ fn optional_message_or_newlines(input: &str) -> IResult<&str, Vec<&str>> { /// /// An attribute that does not have a value is valid. /// ``` -/// # use rpsl_parser::{parse_object, Attribute, Object}; +/// # use rpsl_parser::{parse_object, object}; /// # fn main() -> Result<(), Box> { /// let without_value = " /// as-name: REMARKABLE @@ -128,11 +124,11 @@ fn optional_message_or_newlines(input: &str) -> IResult<&str, Vec<&str>> { /// "; /// assert_eq!( /// parse_object(without_value)?, -/// Object::new(vec![ -/// Attribute::new("as-name", "REMARKABLE")?, -/// Attribute::without_value("remarks")?, -/// Attribute::new("remarks", "^^^^^^^^^^ nothing here")?, -/// ]) +/// object! { +/// "as-name": "REMARKABLE"; +/// "remarks": ""; +/// "remarks": "^^^^^^^^^^ nothing here"; +/// } /// ); /// # Ok(()) /// # } @@ -142,7 +138,7 @@ fn optional_message_or_newlines(input: &str) -> IResult<&str, Vec<&str>> { /// Since whitespace to the left of a value is trimmed, they are equivalent to no value. /// /// ``` -/// # use rpsl_parser::{parse_object, Attribute, Object}; +/// # use rpsl_parser::{parse_object, object}; /// # fn main() -> Result<(), Box> { /// let whitespace_value = " /// as-name: REMARKABLE @@ -152,29 +148,29 @@ fn optional_message_or_newlines(input: &str) -> IResult<&str, Vec<&str>> { /// "; /// assert_eq!( /// parse_object(whitespace_value)?, -/// Object::new(vec![ -/// Attribute::new("as-name", "REMARKABLE")?, -/// Attribute::without_value("remarks")?, -/// Attribute::new("remarks", "^^^^^^^^^^ nothing but hot air")?, -/// ]) +/// object! { +/// "as-name": "REMARKABLE"; +/// "remarks": ""; +/// "remarks": "^^^^^^^^^^ nothing but hot air"; +/// } /// ); /// # Ok(()) /// # } /// ``` -pub fn parse_object(rpsl: &str) -> Result> { +pub fn parse_object(rpsl: &str) -> Result> { let (_, object) = all_consuming(delimited(multispace0, object_block, multispace0))(rpsl).finish()?; Ok(object) } -/// Parse a whois server response containing multiple RPSL objects in their textual representation. +/// Parse a WHOIS server response into [`ObjectView`]s of the objects contained within. /// /// # Errors /// Returns a Nom error if the input is not valid RPSL. /// /// # Examples /// ``` -/// # use rpsl_parser::{parse_whois_response, Attribute, Object}; +/// # use rpsl_parser::{parse_whois_response, object}; /// # fn main() -> Result<(), Box> { /// let whois_response = " /// ASNumber: 32934 @@ -198,37 +194,37 @@ pub fn parse_object(rpsl: &str) -> Result> { /// Ref: https://rdap.arin.net/registry/entity/THEFA-3 /// /// "; -/// let objects: Vec = parse_whois_response(whois_response)?; +/// let objects = parse_whois_response(whois_response)?; /// assert_eq!( /// objects, /// vec![ -/// Object::new(vec![ -/// Attribute::new("ASNumber", "32934")?, -/// Attribute::new("ASName", "FACEBOOK")?, -/// Attribute::new("ASHandle", "AS32934")?, -/// Attribute::new("RegDate", "2004-08-24")?, -/// Attribute::new("Updated", "2012-02-24")?, -/// Attribute::new("Comment", "Please send abuse reports to abuse@facebook.com")?, -/// Attribute::new("Ref", "https://rdap.arin.net/registry/autnum/32934")?, -/// ]), -/// Object::new(vec![ -/// Attribute::new("OrgName", "Facebook, Inc.")?, -/// Attribute::new("OrgId", "THEFA-3")?, -/// Attribute::new("Address", "1601 Willow Rd.")?, -/// Attribute::new("City", "Menlo Park")?, -/// Attribute::new("StateProv", "CA")?, -/// Attribute::new("PostalCode", "94025")?, -/// Attribute::new("Country", "US")?, -/// Attribute::new("RegDate", "2004-08-11")?, -/// Attribute::new("Updated", "2012-04-17")?, -/// Attribute::new("Ref", "https://rdap.arin.net/registry/entity/THEFA-3")?, -/// ]), +/// object! { +/// "ASNumber": "32934"; +/// "ASName": "FACEBOOK"; +/// "ASHandle": "AS32934"; +/// "RegDate": "2004-08-24"; +/// "Updated": "2012-02-24"; +/// "Comment": "Please send abuse reports to abuse@facebook.com"; +/// "Ref": "https://rdap.arin.net/registry/autnum/32934"; +/// }, +/// object! { +/// "OrgName": "Facebook, Inc."; +/// "OrgId": "THEFA-3"; +/// "Address": "1601 Willow Rd."; +/// "City": "Menlo Park"; +/// "StateProv": "CA"; +/// "PostalCode": "94025"; +/// "Country": "US"; +/// "RegDate": "2004-08-11"; +/// "Updated": "2012-04-17"; +/// "Ref": "https://rdap.arin.net/registry/entity/THEFA-3"; +/// } /// ] /// ); /// # Ok(()) /// # } -pub fn parse_whois_response(response: &str) -> Result, Error<&str>> { - let (_, objects): (&str, Vec) = +pub fn parse_whois_response(response: &str) -> Result, Error<&str>> { + let (_, objects): (&str, Vec) = all_consuming(many1(padded_object_block))(response).finish()?; Ok(objects) } @@ -236,7 +232,7 @@ pub fn parse_whois_response(response: &str) -> Result, Error<&str>> #[cfg(test)] mod tests { use super::*; - use crate::Attribute; + use crate::{AttributeView, ObjectView}; #[test] fn object_block_valid() { @@ -249,10 +245,13 @@ mod tests { object_block(object), Ok(( "", - Object::new(vec![ - Attribute::new("email", "rpsl-parser@github.com").unwrap(), - Attribute::new("nic-hdl", "RPSL1-RIPE").unwrap() - ]) + ObjectView::new( + vec![ + AttributeView::new_single("email", "rpsl-parser@github.com"), + AttributeView::new_single("nic-hdl", "RPSL1-RIPE") + ], + Some(object) + ) )) ); } diff --git a/src/rpsl.rs b/src/rpsl.rs index 2a46ff1..a15c3fe 100644 --- a/src/rpsl.rs +++ b/src/rpsl.rs @@ -1,7 +1,8 @@ -pub use self::attribute::Attribute; -pub use self::object::Object; +pub use self::borrowed::{AttributeView, ObjectView}; +pub use self::owned::{Attribute, Name, Object, Value}; -pub(crate) mod attribute; +mod borrowed; +mod common; #[allow(clippy::module_name_repetitions)] pub mod error; -mod object; +mod owned; diff --git a/src/rpsl/borrowed.rs b/src/rpsl/borrowed.rs new file mode 100644 index 0000000..b91c83e --- /dev/null +++ b/src/rpsl/borrowed.rs @@ -0,0 +1,7 @@ +pub use self::attribute::AttributeView; +pub use self::object::ObjectView; + +#[allow(clippy::module_name_repetitions)] +mod attribute; +#[allow(clippy::module_name_repetitions)] +mod object; diff --git a/src/rpsl/borrowed/attribute.rs b/src/rpsl/borrowed/attribute.rs new file mode 100644 index 0000000..8143e07 --- /dev/null +++ b/src/rpsl/borrowed/attribute.rs @@ -0,0 +1,286 @@ +use crate::rpsl::common::coerce_empty_value; + +/// A type containing a string slice pointing to the name of an attribute. +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct NameView<'a>(&'a str); + +impl<'a> NameView<'a> { + pub(crate) fn new(name: &'a str) -> Self { + Self(name) + } + + pub fn as_str(&self) -> &'a str { + self.0 + } + + pub fn to_owned(&self) -> crate::rpsl::Name { + crate::rpsl::Name::new(self.0.to_owned()) + } +} + +impl PartialEq<&str> for NameView<'_> { + fn eq(&self, other: &&str) -> bool { + self.0 == *other + } +} + +#[allow(clippy::from_over_into)] +impl<'a> Into<&'a str> for NameView<'a> { + fn into(self) -> &'a str { + self.as_str() + } +} + +/// A type containing a string slice pointing to the value of an attribute. +#[derive(Debug, PartialEq, Eq, Clone)] +pub enum ValueView<'a> { + SingleLine(Option<&'a str>), + MultiLine(Vec>), +} + +impl<'a> ValueView<'a> { + pub(crate) fn new_single(value: &'a str) -> Self { + Self::SingleLine(coerce_empty_value(value)) + } + pub(crate) fn new_multi(values: Vec<&'a str>) -> Self { + Self::MultiLine(values.into_iter().map(coerce_empty_value).collect()) + } + /// The number of values referenced within the view. + pub fn len(&self) -> usize { + match &self { + ValueView::SingleLine(_) => 1, + ValueView::MultiLine(values) => values.len(), + } + } + + pub fn to_owned(&self) -> crate::rpsl::Value { + match self { + Self::SingleLine(value) => { + crate::rpsl::Value::new_single(value.map_or(None, |v| Some(v.to_string()))) + } + Self::MultiLine(values) => crate::rpsl::Value::new_multi( + values + .iter() + .map(|v| v.map_or(None, |v| Some(v.to_string()))) + .collect(), + ), + } + } +} + +impl<'a> IntoIterator for ValueView<'a> { + type Item = Option<&'a str>; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + match self { + Self::SingleLine(value) => vec![value].into_iter(), + Self::MultiLine(values) => values.into_iter(), + } + } +} + +impl PartialEq<&str> for ValueView<'_> { + fn eq(&self, other: &&str) -> bool { + match self { + ValueView::MultiLine(_) => false, + ValueView::SingleLine(value) => match value { + Some(value) => value == other, + None => coerce_empty_value(other).is_none(), + }, + } + } +} + +impl PartialEq> for ValueView<'_> { + fn eq(&self, other: &Vec<&str>) -> bool { + match self { + ValueView::SingleLine(_) => false, + ValueView::MultiLine(values) => { + if values.len() != other.len() { + return false; + } + for (s, o) in values.iter().zip(other.iter()) { + match s { + Some(value) => { + if value != o { + return false; + } + } + None => { + if coerce_empty_value(o).is_some() { + return false; + } + } + } + } + true + } + } + } +} + +impl PartialEq>> for ValueView<'_> { + fn eq(&self, other: &Vec>) -> bool { + match self { + ValueView::SingleLine(_) => false, + ValueView::MultiLine(values) => { + if values.len() != other.len() { + return false; + } + + for (s, o) in values.iter().zip(other.iter()) { + if s != o { + return false; + } + } + true + } + } + } +} + +/// A view into an attribute of an RPSL object in textual representation somewhere in memory. +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct AttributeView<'a> { + /// The name of the referenced attribute. + pub name: NameView<'a>, + /// The value of the referenced attribute. + pub value: ValueView<'a>, +} + +impl<'a> AttributeView<'a> { + pub(crate) fn new_single(name: &'a str, value: &'a str) -> Self { + let name = NameView::new(name); + let value = ValueView::new_single(value); + Self { name, value } + } + + pub(crate) fn new_multi(name: &'a str, values: Vec<&'a str>) -> Self { + let name = NameView::new(name); + let value = ValueView::new_multi(values); + Self { name, value } + } + + #[must_use] + /// Turn the view into an owned [`Attribute`](crate::Attribute). + pub fn to_owned(&self) -> crate::rpsl::Attribute { + crate::rpsl::Attribute::new(self.name.to_owned(), self.value.to_owned()) + } +} + +impl PartialEq for AttributeView<'_> { + fn eq(&self, other: &crate::rpsl::Attribute) -> bool { + other.name == self.name.as_str() && { + // TODO: Avoid unnecessary allocations. + let s: Vec> = self.value.clone().into_iter().collect(); + let o: Vec> = other.value.clone().into_iter().collect(); + for (s, o) in s.iter().zip(o.iter()) { + if s != &o.as_deref() { + return false; + } + } + true + } + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn value_len() { + assert_eq!(ValueView::new_single("single value").len(), 1); + assert_eq!( + ValueView::new_multi(vec!["multi", "value", "attribute"]).len(), + 3 + ); + } + + #[test] + fn value_eq_is_eq() { + assert_eq!(ValueView::SingleLine(Some("single value")), "single value"); + assert_eq!(ValueView::SingleLine(None), " "); + assert_eq!( + ValueView::MultiLine(vec![Some("multi"), Some("value"), Some("attribute")]), + vec!["multi", "value", "attribute"] + ); + assert_eq!( + ValueView::MultiLine(vec![Some("multi"), None, Some("attribute")]), + vec!["multi", " ", "attribute"] + ); + assert_eq!( + ValueView::MultiLine(vec![Some("multi"), Some("value"), Some("attribute")]), + vec![Some("multi"), Some("value"), Some("attribute")] + ); + assert_eq!( + ValueView::MultiLine(vec![Some("multi"), None, Some("attribute")]), + vec![Some("multi"), None, Some("attribute")] + ); + } + + #[test] + fn value_ne_is_ne() { + assert_ne!( + ValueView::SingleLine(Some("single value")), + "other single value" + ); + assert_ne!(ValueView::SingleLine(None), "not none"); + assert_ne!( + ValueView::SingleLine(Some("single value")), + vec!["other", "multi", "value", "attribute"] + ); + assert_ne!( + ValueView::MultiLine(vec![Some("multi"), Some("value"), Some("attribute")]), + vec!["other", "multi", "value", "attribute"] + ); + assert_ne!( + ValueView::MultiLine(vec![Some("multi"), Some("value"), Some("attribute")]), + vec![Some("multi"), None, Some("attribute")] + ); + assert_ne!( + ValueView::MultiLine(vec![Some("multi"), None, Some("attribute")]), + vec![Some("multi"), Some(" "), Some("attribute")] + ); + } + #[test] + fn name_view_comparable_to_str() { + assert_eq!(NameView::new("person"), "person"); + } + + #[test] + fn eq_owned_attribute_is_eq() { + assert_eq!( + AttributeView::new_single("as-name", "REMARKABLE"), + crate::rpsl::Attribute::new("as-name".parse().unwrap(), "REMARKABLE".parse().unwrap()) + ); + assert_eq!( + AttributeView::new_multi("remarks", vec!["Equal", "Values"]), + crate::rpsl::Attribute::new( + "remarks".parse().unwrap(), + std::convert::TryInto::::try_into(vec!["Equal", "Values"]) + .unwrap() + ) + ); + } + + #[test] + fn ne_owned_attribute_ne() { + assert_ne!( + AttributeView::new_single("as-name", "REMARKABLE"), + crate::rpsl::Attribute::new( + "as-name".parse().unwrap(), + "UNREMARKABLE".parse().unwrap() + ) + ); + assert_ne!( + AttributeView::new_multi("remarks", vec!["Some", "Values"]), + crate::rpsl::Attribute::new( + "remarks".parse().unwrap(), + std::convert::TryInto::::try_into(vec!["Different", "Values"]) + .unwrap() + ) + ); + } +} diff --git a/src/rpsl/borrowed/object.rs b/src/rpsl/borrowed/object.rs new file mode 100644 index 0000000..c7c4d37 --- /dev/null +++ b/src/rpsl/borrowed/object.rs @@ -0,0 +1,279 @@ +use super::attribute::AttributeView; +use std::{fmt, ops::Index}; + +/// A view into an RPSL object in textual representation somewhere in memory. +/// +/// This is the borrowed equivalent of an [`Object`], only containing references to the +/// original data in the form of [`AttributeView`]s. It presents largely the same interface as +/// its owned equivalent, although it will always return references. +/// +/// +/// ```text +/// role: ACME Company ◀─────────────── &"role" ─── &"ACME Company" +/// address: Packet Street 6 ◀──────────── &"address" ─┬─ &"Packet Street 6" +/// 128 Series of Tubes ◀──────── ├─ &"128 Series of Tubes" +/// Internet ◀─────────────────── └─ &"Internet" +/// email: rpsl-parser@github.com ◀───── &"email" ─── &"rpsl-parser@github.com" +/// nic-hdl: RPSL1-RIPE ◀───────────────── &"nic-hdl" ─── &"RPSL1-RIPE" +/// source: RIPE ◀─────────────────────── &"source" ─── &"RIPE" +/// ``` +/// +/// Since an [`ObjectView`] is purely used to provide a view into referenced RPSL data, it can only +/// be created from RPSL text using the [`parse_object`] and [`parse_whois_response`](crate::parse_whois_response) functions. +/// +/// # Examples +/// +/// Like an owned [`Object`], its attributes can be accessed by index. +/// ``` +/// # use rpsl_parser::{parse_object, Attribute}; +/// # fn main() -> Result<(), Box> { +/// # let role_acme = parse_object(" +/// # role: ACME Company +/// # address: Packet Street +/// # 128 Series of Tubes +/// # Internet +/// # email: rpsl-parser@github.com +/// # nic-hdl: RPSL1-RIPE +/// # source: RIPE +/// # +/// # ")?; +/// assert_eq!(role_acme[0], Attribute::new("role".parse()?, "ACME Company".parse()?)); +/// assert_eq!(role_acme[3], Attribute::new("nic-hdl".parse()?, "RPSL1-RIPE".parse()?)); +/// # Ok(()) +/// # } +/// ``` +/// +/// While specific attribute values can be accessed by name. +/// ``` +/// # use rpsl_parser::{parse_object, Attribute}; +/// # fn main() -> Result<(), Box> { +/// # let role_acme = parse_object(" +/// # role: ACME Company +/// # address: Packet Street 6 +/// # 128 Series of Tubes +/// # Internet +/// # email: rpsl-parser@github.com +/// # nic-hdl: RPSL1-RIPE +/// # source: RIPE +/// # +/// # ")?; +/// assert_eq!(role_acme.get("role"), vec!["ACME Company"]); +/// assert_eq!(role_acme.get("address"), vec!["Packet Street 6", "128 Series of Tubes", "Internet"]); +/// assert_eq!(role_acme.get("email"), vec!["rpsl-parser@github.com"]); +/// assert_eq!(role_acme.get("nic-hdl"), vec!["RPSL1-RIPE"]); +/// assert_eq!(role_acme.get("source"), vec!["RIPE"]); +/// # Ok(()) +/// # } +/// ``` +/// +/// Views can be compared to their owned equivalents. +/// ``` +/// # use rpsl_parser::{parse_object, Attribute, object}; +/// # fn main() -> Result<(), Box> { +/// # let role_acme = parse_object(" +/// # role: ACME Company +/// # address: Packet Street 6 +/// # 128 Series of Tubes +/// # Internet +/// # email: rpsl-parser@github.com +/// # nic-hdl: RPSL1-RIPE +/// # source: RIPE +/// # +/// # ")?; +/// assert_eq!( +/// role_acme, +/// object! { +/// "role": "ACME Company"; +/// "address": "Packet Street 6", "128 Series of Tubes", "Internet"; +/// "email": "rpsl-parser@github.com"; +/// "nic-hdl": "RPSL1-RIPE"; +/// "source": "RIPE"; +/// }, +/// ); +/// # Ok(()) +/// # } +/// ``` +/// +/// As well as converted to them if required. +/// ``` +/// # use rpsl_parser::{parse_object, Attribute}; +/// # fn main() -> Result<(), Box> { +/// # let role_acme = parse_object(" +/// # role: ACME Company +/// # address: Packet Street 6 +/// # 128 Series of Tubes +/// # Internet +/// # email: rpsl-parser@github.com +/// # nic-hdl: RPSL1-RIPE +/// # source: RIPE +/// # +/// # ")?; +/// role_acme.to_owned(); +/// # Ok(()) +/// # } +/// ``` +/// [`Object`]: crate::Object +/// [`parse_object`]: crate::parse_object +/// [`parse_whois_response`]: crate::parse_whois_response +#[derive(PartialEq, Eq, Clone)] +#[allow(clippy::len_without_is_empty)] +pub struct ObjectView<'a> { + attributes: Vec>, + /// The original RPSL text that was parsed to create this view. + source: Option<&'a str>, +} + +impl<'a> ObjectView<'a> { + pub(crate) fn new(attributes: Vec>, source: Option<&'a str>) -> Self { + Self { + attributes, + source: source.map(str::trim), + } + } + + /// Turn the view into an owned [`Object`](crate::Object). + pub fn to_owned(&self) -> crate::rpsl::Object { + crate::rpsl::Object::new( + self.attributes + .iter() + .map(AttributeView::to_owned) + .collect(), + ) + } + + /// The number of attributes referenced within the view. + #[must_use] + pub fn len(&self) -> usize { + self.attributes.len() + } + + /// Get the value(s) of specific attribute(s). + pub fn get(&self, name: &str) -> Vec<&str> { + let values_matching_name = self + .attributes + .iter() + .filter(|a| a.name == name) + .map(|a| &a.value); + + let mut values: Vec<&str> = Vec::new(); + for value in values_matching_name { + match value { + super::attribute::ValueView::SingleLine(v) => { + if let Some(v) = v { + values.push(v); + } + } + super::attribute::ValueView::MultiLine(v) => { + values.extend(v.iter().filter_map(Option::as_ref)); + } + } + } + values + } +} + +impl PartialEq for ObjectView<'_> { + fn eq(&self, other: &crate::rpsl::Object) -> bool { + // TODO: Avoid cloning + for (s, o) in self.clone().into_iter().zip(other.clone().into_iter()) { + if s != o { + return false; + } + } + true + } +} + +impl<'a> Index for ObjectView<'a> { + type Output = AttributeView<'a>; + + fn index(&self, index: usize) -> &Self::Output { + &self.attributes[index] + } +} + +impl<'a> IntoIterator for ObjectView<'a> { + type Item = AttributeView<'a>; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.attributes.into_iter() + } +} + +impl fmt::Debug for ObjectView<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:#?}", self.attributes) + } +} + +impl fmt::Display for ObjectView<'_> { + /// Display the view as RPSL. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if let Some(source) = self.source { + writeln!(f, "{source}")?; + writeln!(f)?; + } else { + // If the source is not available, fall back to the implementation of the owned type. + write!(f, "{}", self.to_owned())?; + } + Ok(()) + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn eq_owned_object_is_eq() { + let borrowed = ObjectView::new( + vec![ + AttributeView::new_single("role", "ACME Company"), + AttributeView::new_single("address", "Packet Street 6"), + AttributeView::new_single("address", "128 Series of Tubes"), + AttributeView::new_single("address", "Internet"), + ], + None, + ); + let owned = crate::rpsl::Object::new(vec![ + crate::rpsl::Attribute::new("role".parse().unwrap(), "ACME Company".parse().unwrap()), + crate::rpsl::Attribute::new( + "address".parse().unwrap(), + "Packet Street 6".parse().unwrap(), + ), + crate::rpsl::Attribute::new( + "address".parse().unwrap(), + "128 Series of Tubes".parse().unwrap(), + ), + crate::rpsl::Attribute::new("address".parse().unwrap(), "Internet".parse().unwrap()), + ]); + assert_eq!(borrowed, owned); + } + + #[test] + fn ne_owned_object_is_ne() { + let borrowed = ObjectView::new( + vec![ + AttributeView::new_single("role", "Umbrella Corporation"), + AttributeView::new_single("address", "Paraguas Street"), + AttributeView::new_single("address", "Raccoon City"), + AttributeView::new_single("address", "Colorado"), + ], + None, + ); + let owned = crate::rpsl::Object::new(vec![ + crate::rpsl::Attribute::new("role".parse().unwrap(), "ACME Company".parse().unwrap()), + crate::rpsl::Attribute::new( + "address".parse().unwrap(), + "Packet Street 6".parse().unwrap(), + ), + crate::rpsl::Attribute::new( + "address".parse().unwrap(), + "128 Series of Tubes".parse().unwrap(), + ), + crate::rpsl::Attribute::new("address".parse().unwrap(), "Internet".parse().unwrap()), + ]); + assert_ne!(borrowed, owned); + } +} diff --git a/src/rpsl/common.rs b/src/rpsl/common.rs new file mode 100644 index 0000000..7442b4b --- /dev/null +++ b/src/rpsl/common.rs @@ -0,0 +1,11 @@ +/// Coerce an empty value to `None`. +pub(super) fn coerce_empty_value(value: S) -> Option +where + S: AsRef, +{ + if value.as_ref().trim().is_empty() { + None + } else { + Some(value) + } +} diff --git a/src/rpsl/error.rs b/src/rpsl/error.rs index 7cc449b..1004be5 100644 --- a/src/rpsl/error.rs +++ b/src/rpsl/error.rs @@ -4,10 +4,21 @@ use thiserror::Error; pub enum InvalidNameError { #[error("cannot be empty")] Empty, + #[error("cannot contain non-ASCII characters")] + NonAscii, + #[error("cannot start with a non-letter ASCII character")] + NonAsciiAlphabeticFirstChar, + #[error("cannot end with a non-letter or non-digit ASCII character")] + NonAsciiAlphanumericLastChar, } #[derive(Error, Debug)] -pub enum InvalidValueError {} +pub enum InvalidValueError { + #[error("cannot contain non-ASCII characters")] + NonAscii, + #[error("cannot contain ASCII control characters")] + ContainsControlChar, +} #[derive(Error, Debug)] /// An error that can occur when parsing or trying to create an attribute that is invalid. diff --git a/src/rpsl/owned.rs b/src/rpsl/owned.rs new file mode 100644 index 0000000..d97092b --- /dev/null +++ b/src/rpsl/owned.rs @@ -0,0 +1,6 @@ +pub use self::attribute::{Attribute, Name, Value}; +pub use self::object::Object; + +#[allow(clippy::module_name_repetitions)] +mod attribute; +mod object; diff --git a/src/rpsl/owned/attribute.rs b/src/rpsl/owned/attribute.rs new file mode 100644 index 0000000..0e78606 --- /dev/null +++ b/src/rpsl/owned/attribute.rs @@ -0,0 +1,480 @@ +use crate::rpsl::{ + common::coerce_empty_value, + error::{InvalidNameError, InvalidValueError}, +}; +use std::{fmt, str::FromStr}; + +/// The name of an attribute. +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct Name(String); + +impl Name { + /// Create a new `Name` from a String without validation. + pub(in crate::rpsl) fn new(name: String) -> Self { + Self(name) + } +} + +impl FromStr for Name { + type Err = InvalidNameError; + + /// Create a new `Name` from a string slice. + /// + /// A valid name may consist of ASCII letters, digits and the characters "-", "_", + /// while beginning with a letter and ending with a letter or a digit. + /// + /// # Errors + /// Returns an error if the name is empty or invalid. + fn from_str(name: &str) -> Result { + if name.trim().is_empty() { + return Err(InvalidNameError::Empty); + } else if !name.is_ascii() { + return Err(InvalidNameError::NonAscii); + } else if !name.chars().next().unwrap().is_ascii_alphabetic() { + return Err(InvalidNameError::NonAsciiAlphabeticFirstChar); + } else if !name.chars().last().unwrap().is_ascii_alphanumeric() { + return Err(InvalidNameError::NonAsciiAlphanumericLastChar); + } + + Ok(Self(name.to_string())) + } +} + +impl PartialEq<&str> for Name { + fn eq(&self, other: &&str) -> bool { + self.0 == *other + } +} + +impl fmt::Display for Name { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +/// The value of an attribute. +#[derive(Debug, PartialEq, Eq, Clone)] +pub enum Value { + SingleLine(Option), + MultiLine(Vec>), +} + +impl Value { + fn validate(value: &str) -> Result<(), InvalidValueError> { + if !value.is_ascii() { + return Err(InvalidValueError::NonAscii); + } else if value.chars().any(|c| c.is_ascii_control()) { + return Err(InvalidValueError::ContainsControlChar); + } + + Ok(()) + } + + /// Create a new `Value` from a String without validation. + pub(in crate::rpsl) fn new_single(value: Option) -> Self { + Self::SingleLine(value) + } + + /// Create a new `Value` from a vector of strings without validation. + pub(in crate::rpsl) fn new_multi(values: Vec>) -> Self { + Self::MultiLine(values) + } + + /// The number of values contained within. + pub fn len(&self) -> usize { + match &self { + Value::SingleLine(_) => 1, + Value::MultiLine(values) => values.len(), + } + } +} + +impl FromStr for Value { + type Err = InvalidValueError; + + /// Create a new single line `Value` from a string slice. + /// + /// A valid value may consist of any ASCII character, excluding control characters. + /// + /// # Errors + /// Returns an error if the value contains invalid characters. + fn from_str(value: &str) -> Result { + Self::validate(value)?; + Ok(Self::SingleLine( + coerce_empty_value(value).map(std::string::ToString::to_string), + )) + } +} + +impl TryFrom> for Value { + type Error = InvalidValueError; + + /// Create a new `Value` from a vector of string slices. + /// + /// A valid value may consist of any ASCII character, excluding control characters. + /// + /// # Errors + /// Returns an error if a value contains invalid characters. + fn try_from(values: Vec<&str>) -> Result { + if values.len() == 1 { + let value = values[0].parse()?; + return Ok(value); + } + let values = values + .into_iter() + .map(|v| { + Self::validate(v)?; + Ok(coerce_empty_value(v).map(std::string::ToString::to_string)) + }) + .collect::>, InvalidValueError>>()?; + + Ok(Self::MultiLine(values)) + } +} + +impl IntoIterator for Value { + type Item = Option; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + match self { + Self::SingleLine(value) => vec![value].into_iter(), + Self::MultiLine(values) => values.into_iter(), + } + } +} + +impl PartialEq<&str> for Value { + fn eq(&self, other: &&str) -> bool { + match &self { + Self::MultiLine(_) => false, + Self::SingleLine(value) => match value { + Some(value) => value == *other, + None => coerce_empty_value(other).is_none(), + }, + } + } +} + +impl PartialEq> for Value { + fn eq(&self, other: &Vec<&str>) -> bool { + match &self { + Self::SingleLine(_) => false, + Self::MultiLine(values) => { + if values.len() != other.len() { + return false; + } + + let other_coerced = other.iter().map(|&v| coerce_empty_value(v)); + + for (s, o) in values.iter().zip(other_coerced) { + if s.as_deref() != o { + return false; + } + } + + true + } + } + } +} + +impl PartialEq>> for Value { + fn eq(&self, other: &Vec>) -> bool { + match &self { + Self::SingleLine(_) => false, + Self::MultiLine(values) => { + if values.len() != other.len() { + return false; + } + + for (s, o) in values.iter().zip(other.iter()) { + if s.as_deref() != *o { + return false; + } + } + + true + } + } + } +} + +#[derive(Debug, PartialEq, Eq, Clone)] +/// An attribute of an RPSL [`Object`](crate::Object). +pub struct Attribute { + /// The name of the attribute. + pub name: Name, + /// The value(s) of the attribute. + pub value: Value, +} + +impl Attribute { + /// Create a new attribute. + #[must_use] + pub fn new(name: Name, value: Value) -> Self { + Self { name, value } + } +} + +impl fmt::Display for Attribute { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self.value { + Value::SingleLine(value) => { + writeln!(f, "{:16}{}", format!("{}:", self.name), { + match value { + Some(value) => value, + None => "", + } + }) + } + Value::MultiLine(values) => { + writeln!(f, "{:16}{}", format!("{}:", self.name), { + match &values[0] { + Some(value) => value, + None => "", + } + })?; + + let mut continuation_values = String::new(); + for value in &values[1..] { + continuation_values.push_str(&format!("{:16}{}\n", "", { + match &value { + Some(value) => value, + None => "", + } + })); + } + write!(f, "{continuation_values}") + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use proptest::prelude::*; + + #[test] + fn name_from_str() { + assert_eq!("role".parse::().unwrap().0, String::from("role")); + assert_eq!("person".parse::().unwrap().0, String::from("person")); + } + + proptest! { + #[test] + fn name_from_str_space_only_is_err(n in r"\s") { + assert!(n.parse::().is_err()); + } + + #[test] + fn name_from_str_non_ascii_is_err(n in r"[^[[:ascii:]]]") { + assert!(n.parse::().is_err()); + } + + #[test] + fn name_from_str_non_letter_first_char_is_err(n in r"[^a-zA-Z][[:ascii:]]*") { + assert!(n.parse::().is_err()); + } + + #[test] + fn name_from_str_non_letter_or_digit_last_char_is_err(n in r"[[:ascii:]]*[^a-zA-Z0-9]") { + assert!(n.parse::().is_err()); + } + } + + #[test] + fn value_from_str() { + let value = "This is a valid attribute value"; + assert_eq!( + value.parse::().unwrap(), + Value::SingleLine(Some(value.to_string())) + ); + } + + #[test] + fn value_from_empty_str() { + let value = " "; + assert_eq!(value.parse::().unwrap(), Value::SingleLine(None)); + } + + #[test] + fn value_len() { + assert_eq!("single value".parse::().unwrap().len(), 1); + assert_eq!( + std::convert::TryInto::::try_into(vec!["multi", "value", "attribute"]) + .unwrap() + .len(), + 3 + ); + } + + #[test] + fn value_eq_is_eq() { + assert_eq!( + Value::SingleLine(Some("single value".to_string())), + "single value" + ); + assert_eq!(Value::SingleLine(None), " "); + assert_eq!( + Value::MultiLine(vec![ + Some("multi".to_string()), + Some("value".to_string()), + Some("attribute".to_string()) + ]), + vec!["multi", "value", "attribute"] + ); + assert_eq!( + Value::MultiLine(vec![ + Some("multi".to_string()), + None, + Some("attribute".to_string()) + ]), + vec!["multi", " ", "attribute"] + ); + assert_eq!( + Value::MultiLine(vec![ + Some("multi".to_string()), + Some("value".to_string()), + Some("attribute".to_string()) + ]), + vec![Some("multi"), Some("value"), Some("attribute")] + ); + assert_eq!( + Value::MultiLine(vec![ + Some("multi".to_string()), + None, + Some("attribute".to_string()) + ]), + vec![Some("multi"), None, Some("attribute")] + ); + } + + #[test] + fn value_ne_is_ne() { + assert_ne!( + Value::SingleLine(Some("single value".to_string())), + "other single value" + ); + assert_ne!(Value::SingleLine(None), "not none"); + assert_ne!( + Value::SingleLine(Some("single value".to_string())), + vec!["other", "multi", "value", "attribute"] + ); + assert_ne!( + Value::MultiLine(vec![ + Some("multi".to_string()), + Some("value".to_string()), + Some("attribute".to_string()) + ]), + vec!["other", "multi", "value", "attribute"] + ); + assert_ne!( + Value::MultiLine(vec![ + Some("multi".to_string()), + Some("value".to_string()), + Some("attribute".to_string()) + ]), + vec![Some("multi"), None, Some("attribute")] + ); + assert_ne!( + Value::MultiLine(vec![ + Some("multi".to_string()), + None, + Some("attribute".to_string()) + ]), + vec![Some("multi"), Some(" "), Some("attribute")] + ); + } + + proptest! { + #[test] + fn value_from_str_non_ascii_is_err(v in r"[^[[:ascii:]]]") { + assert!(v.parse::().is_err()); + } + + #[test] + fn value_from_str_ascii_control_is_err(v in r"[[:cntrl:]]") { + assert!(v.parse::().is_err()); + } + } + + #[test] + fn value_from_vec_of_str() { + assert_eq!( + Value::try_from(vec!["Packet Street 6", "128 Series of Tubes", "Internet"]).unwrap(), + Value::MultiLine(vec![ + Some("Packet Street 6".to_string()), + Some("128 Series of Tubes".to_string()), + Some("Internet".to_string()) + ]) + ); + assert_eq!( + Value::try_from(vec!["", "128 Series of Tubes", "Internet"]).unwrap(), + Value::MultiLine(vec![ + None, + Some("128 Series of Tubes".to_string()), + Some("Internet".to_string()) + ]) + ); + assert_eq!( + Value::try_from(vec!["", " ", " "]).unwrap(), + Value::MultiLine(vec![None, None, None]) + ); + } + + #[test] + fn value_from_vec_w_1_value_is_single_line() { + assert_eq!( + Value::try_from(vec!["Packet Street 6"]).unwrap(), + Value::SingleLine(Some("Packet Street 6".to_string())) + ); + } + + #[test] + fn attribute_display_single_line() { + assert_eq!( + Attribute::new("ASNumber".parse().unwrap(), "32934".parse().unwrap()).to_string(), + "ASNumber: 32934\n" + ); + assert_eq!( + Attribute::new("ASName".parse().unwrap(), "FACEBOOK".parse().unwrap()).to_string(), + "ASName: FACEBOOK\n" + ); + assert_eq!( + Attribute::new("RegDate".parse().unwrap(), "2004-08-24".parse().unwrap()).to_string(), + "RegDate: 2004-08-24\n" + ); + assert_eq!( + Attribute::new( + "Ref".parse().unwrap(), + "https://rdap.arin.net/registry/autnum/32934" + .parse() + .unwrap() + ) + .to_string(), + "Ref: https://rdap.arin.net/registry/autnum/32934\n" + ); + } + + #[test] + fn attribute_display_multi_line() { + assert_eq!( + Attribute::new( + "remarks".parse().unwrap(), + vec![ + "AS1299 is matching RPKI validation state and reject", + "invalid prefixes from peers and customers." + ] + .try_into() + .unwrap() + ) + .to_string(), + concat!( + "remarks: AS1299 is matching RPKI validation state and reject\n", + " invalid prefixes from peers and customers.\n", + ) + ); + } +} diff --git a/src/rpsl/owned/object.rs b/src/rpsl/owned/object.rs new file mode 100644 index 0000000..1577ecb --- /dev/null +++ b/src/rpsl/owned/object.rs @@ -0,0 +1,358 @@ +use super::Attribute; +use std::{fmt, ops::Index}; + +/// A RPSL object. +/// +/// ```text +/// ┌───────────────────────────────────────────────┐ +/// │ Object │ +/// ├───────────────────────────────────────────────┤ +/// │ [role] ─── ACME Company │ +/// │ [address] ──┬─ Packet Street 6 │ +/// │ ├─ 128 Series of Tubes │ +/// │ └─ Internet │ +/// │ [email] ─── rpsl-parser@github.com │ +/// │ [nic-hdl] ─── RPSL1-RIPE │ +/// │ [source] ─── RIPE │ +/// └───────────────────────────────────────────────┘ +/// ``` +/// +/// # Examples +/// +/// A role object for the ACME corporation. +/// ``` +/// # use rpsl_parser::{Attribute, Object}; +/// # fn main() -> Result<(), Box> { +/// let role_acme = Object::new(vec![ +/// Attribute::new("role".parse()?, "ACME Company".parse()?), +/// Attribute::new("address".parse()?, "Packet Street 6".parse()?), +/// Attribute::new("address".parse()?, "128 Series of Tubes".parse()?), +/// Attribute::new("address".parse()?, "Internet".parse()?), +/// Attribute::new("email".parse()?, "rpsl-parser@github.com".parse()?), +/// Attribute::new("nic-hdl".parse()?, "RPSL1-RIPE".parse()?), +/// Attribute::new("source".parse()?, "RIPE".parse()?), +/// ]); +/// # Ok(()) +/// # } +/// ``` +/// +/// Although creating an [`Object`] from a vector of [`Attribute`]s works, the more idiomatic way +/// to do it is by using the [`object!`](crate::object) macro. +/// ``` +/// # use rpsl_parser::{Attribute, Object, object}; +/// # fn main() -> Result<(), Box> { +/// # let role_acme = Object::new(vec![ +/// # Attribute::new("role".parse()?, "ACME Company".parse()?), +/// # Attribute::new("address".parse()?, "Packet Street 6".parse()?), +/// # Attribute::new("address".parse()?, "128 Series of Tubes".parse()?), +/// # Attribute::new("address".parse()?, "Internet".parse()?), +/// # Attribute::new("email".parse()?, "rpsl-parser@github.com".parse()?), +/// # Attribute::new("nic-hdl".parse()?, "RPSL1-RIPE".parse()?), +/// # Attribute::new("source".parse()?, "RIPE".parse()?), +/// # ]); +/// assert_eq!( +/// role_acme, +/// object! { +/// "role": "ACME Company"; +/// "address": "Packet Street 6"; +/// "address": "128 Series of Tubes"; +/// "address": "Internet"; +/// "email": "rpsl-parser@github.com"; +/// "nic-hdl": "RPSL1-RIPE"; +/// "source": "RIPE"; +/// }, +/// ); +/// # Ok(()) +/// # } +/// ``` +/// +/// Each attribute can be accessed by index. +/// ``` +/// # use rpsl_parser::{Attribute, Object}; +/// # fn main() -> Result<(), Box> { +/// # let role_acme = Object::new(vec![ +/// # Attribute::new("role".parse()?, "ACME Company".parse()?), +/// # Attribute::new("address".parse()?, "Packet Street 6".parse()?), +/// # Attribute::new("address".parse()?, "128 Series of Tubes".parse()?), +/// # Attribute::new("address".parse()?, "Internet".parse()?), +/// # Attribute::new("email".parse()?, "rpsl-parser@github.com".parse()?), +/// # Attribute::new("nic-hdl".parse()?, "RPSL1-RIPE".parse()?), +/// # Attribute::new("source".parse()?, "RIPE".parse()?), +/// # ]); +/// assert_eq!(role_acme[0], Attribute::new("role".parse()?, "ACME Company".parse()?)); +/// assert_eq!(role_acme[6], Attribute::new("source".parse()?, "RIPE".parse()?)); +/// # Ok(()) +/// # } +/// ``` +/// +/// While specific attribute values can be accessed by name. +/// ``` +/// # use rpsl_parser::{Attribute, Object}; +/// # fn main() -> Result<(), Box> { +/// # let role_acme = Object::new(vec![ +/// # Attribute::new("role".parse()?, "ACME Company".parse()?), +/// # Attribute::new("address".parse()?, "Packet Street 6".parse()?), +/// # Attribute::new("address".parse()?, "128 Series of Tubes".parse()?), +/// # Attribute::new("address".parse()?, "Internet".parse()?), +/// # Attribute::new("email".parse()?, "rpsl-parser@github.com".parse()?), +/// # Attribute::new("nic-hdl".parse()?, "RPSL1-RIPE".parse()?), +/// # Attribute::new("source".parse()?, "RIPE".parse()?), +/// # ]); +/// assert_eq!(role_acme.get("role"), vec!["ACME Company"]); +/// assert_eq!(role_acme.get("address"), vec!["Packet Street 6", "128 Series of Tubes", "Internet"]); +/// assert_eq!(role_acme.get("email"), vec!["rpsl-parser@github.com"]); +/// assert_eq!(role_acme.get("nic-hdl"), vec!["RPSL1-RIPE"]); +/// assert_eq!(role_acme.get("source"), vec!["RIPE"]); +/// # Ok(()) +/// # } +/// ``` +/// +/// The entire object can also be represented as RPSL. +/// ``` +/// # use rpsl_parser::{Attribute, Object}; +/// # fn main() -> Result<(), Box> { +/// # let role_acme = Object::new(vec![ +/// # Attribute::new("role".parse()?, "ACME Company".parse()?), +/// # Attribute::new("address".parse()?, "Packet Street 6".parse()?), +/// # Attribute::new("address".parse()?, "128 Series of Tubes".parse()?), +/// # Attribute::new("address".parse()?, "Internet".parse()?), +/// # Attribute::new("email".parse()?, "rpsl-parser@github.com".parse()?), +/// # Attribute::new("nic-hdl".parse()?, "RPSL1-RIPE".parse()?), +/// # Attribute::new("source".parse()?, "RIPE".parse()?), +/// # ]); +/// assert_eq!( +/// role_acme.to_string(), +/// concat!( +/// "role: ACME Company\n", +/// "address: Packet Street 6\n", +/// "address: 128 Series of Tubes\n", +/// "address: Internet\n", +/// "email: rpsl-parser@github.com\n", +/// "nic-hdl: RPSL1-RIPE\n", +/// "source: RIPE\n", +/// "\n" +/// ) +/// ); +/// # Ok(()) +/// # } +/// ``` +#[derive(Debug, PartialEq, Eq, Clone)] +#[allow(clippy::len_without_is_empty)] +pub struct Object(Vec); + +impl Object { + /// Create a new RPSL object from a vector of attributes. + /// + /// # Example + /// ``` + /// # use rpsl_parser::{Attribute, Object}; + /// # fn main() -> Result<(), Box> { + /// let role_acme = Object::new(vec![ + /// Attribute::new("role".parse()?, "ACME Company".parse()?), + /// Attribute::new("address".parse()?, "Packet Street 6".parse()?), + /// Attribute::new("address".parse()?, "128 Series of Tubes".parse()?), + /// Attribute::new("address".parse()?, "Internet".parse()?), + /// Attribute::new("email".parse()?, "rpsl-parser@github.com".parse()?), + /// Attribute::new("nic-hdl".parse()?, "RPSL1-RIPE".parse()?), + /// Attribute::new("source".parse()?, "RIPE".parse()?), + /// ]); + /// # Ok(()) + /// # } + /// ``` + #[must_use] + pub fn new(attributes: Vec) -> Self { + Object(attributes) + } + + /// The number of attributes in the object. + #[must_use] + pub fn len(&self) -> usize { + self.0.len() + } + + /// Get the value(s) of specific attribute(s). + pub fn get(&self, name: &str) -> Vec<&String> { + let values_matching_name = self.0.iter().filter(|a| a.name == name).map(|a| &a.value); + + let mut values: Vec<&String> = Vec::new(); + for value in values_matching_name { + match value { + super::attribute::Value::SingleLine(ref v) => { + if let Some(v) = v.as_ref() { + values.push(v); + } + } + super::attribute::Value::MultiLine(ref v) => { + values.extend(v.iter().filter_map(Option::as_ref)); + } + } + } + values + } +} + +impl Index for Object { + type Output = Attribute; + + fn index(&self, index: usize) -> &Self::Output { + &self.0[index] + } +} + +impl IntoIterator for Object { + type Item = Attribute; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter() + } +} + +impl fmt::Display for Object { + /// Display the object as RPSL. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for attribute in &self.0 { + write!(f, "{attribute}")?; + } + writeln!(f) + } +} + +/// Creates an [`Object`] containing the given attributes. +/// +/// - Create an [`Object`] containing only single value attributes: +/// ``` +/// # use rpsl_parser::object; +/// # fn main() -> Result<(), Box> { +/// let obj = object! { +/// "role": "ACME Company"; +/// "address": "Packet Street 6"; +/// "address": "128 Series of Tubes"; +/// "address": "Internet"; +/// }; +/// assert_eq!(obj[0].name, "role"); +/// assert_eq!(obj[0].value, "ACME Company"); +/// assert_eq!(obj[1].name, "address"); +/// assert_eq!(obj[1].value, "Packet Street 6"); +/// assert_eq!(obj[2].name, "address"); +/// assert_eq!(obj[2].value, "128 Series of Tubes"); +/// assert_eq!(obj[3].name, "address"); +/// assert_eq!(obj[3].value, "Internet"); +/// # Ok(()) +/// # } +/// ``` +/// +/// - Create an `Object` containing multi value attributes: +/// ``` +/// # use rpsl_parser::object; +/// # fn main() -> Result<(), Box> { +/// let obj = object! { +/// "role": "ACME Company"; +/// "address": "Packet Street 6", "128 Series of Tubes", "Internet"; +/// }; +/// assert_eq!(obj[0].name, "role"); +/// assert_eq!(obj[0].value, "ACME Company"); +/// assert_eq!(obj[1].name, "address"); +/// assert_eq!(obj[1].value, vec!["Packet Street 6", "128 Series of Tubes", "Internet"]); +/// # Ok(()) +/// # } +#[macro_export] +macro_rules! object { + ( + $( + $name:literal: $($value:literal),+ + );+ $(;)? + ) => { + $crate::Object::new(vec![ + $( + $crate::Attribute::new($name.parse().unwrap(), vec![$($value),+].try_into().unwrap()), + )* + ]) + }; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn object_from_macro() { + let object = object! { + "role": "ACME Company"; + "address": "Packet Street 6", "128 Series of Tubes", "Internet"; + "email": "rpsl-parser@github.com"; + "nic-hdl": "RPSL1-RIPE"; + "source": "RIPE"; + }; + let role_acme = Object::new(vec![ + Attribute::new("role".parse().unwrap(), "ACME Company".parse().unwrap()), + Attribute::new( + "address".parse().unwrap(), + vec!["Packet Street 6", "128 Series of Tubes", "Internet"] + .try_into() + .unwrap(), + ), + Attribute::new( + "email".parse().unwrap(), + "rpsl-parser@github.com".parse().unwrap(), + ), + Attribute::new("nic-hdl".parse().unwrap(), "RPSL1-RIPE".parse().unwrap()), + Attribute::new("source".parse().unwrap(), "RIPE".parse().unwrap()), + ]); + assert_eq!(object, role_acme); + } + + #[test] + fn values_by_name() { + let as42 = + Object::new(vec![ + Attribute::new("aut-num".parse().unwrap(), "AS42".parse().unwrap()), + Attribute::new( + "remarks".parse().unwrap(), + "All imported prefixes will be tagged with geographic communities and" + .parse() + .unwrap(), + ), + Attribute::new( + "remarks".parse().unwrap(), + "the type of peering relationship according to the table below, using the default" + .parse() + .unwrap(), + ), + Attribute::new( + "remarks".parse().unwrap(), + "announce rule (x=0).".parse().unwrap(), + ), + Attribute::new("remarks".parse().unwrap(), "".parse().unwrap()), + Attribute::new( + "remarks".parse().unwrap(), + "The following communities can be used by peers and customers".parse().unwrap(), + ), + Attribute::new( + "remarks".parse().unwrap(), + vec![ + "x = 0 - Announce (default rule)", + "x = 1 - Prepend x1", + "x = 2 - Prepend x2", + "x = 3 - Prepend x3", + "x = 9 - Do not announce", + ].try_into().unwrap(), + ), + ]); + assert_eq!(as42.get("aut-num"), vec!["AS42"]); + assert_eq!( + as42.get("remarks"), + vec![ + "All imported prefixes will be tagged with geographic communities and", + "the type of peering relationship according to the table below, using the default", + "announce rule (x=0).", + "The following communities can be used by peers and customers", + "x = 0 - Announce (default rule)", + "x = 1 - Prepend x1", + "x = 2 - Prepend x2", + "x = 3 - Prepend x3", + "x = 9 - Do not announce", + ] + ); + } +} diff --git a/tests/test_display.rs b/tests/test_display.rs new file mode 100644 index 0000000..65e89c0 --- /dev/null +++ b/tests/test_display.rs @@ -0,0 +1,55 @@ +use rpsl_parser::{object, parse_object}; + +#[test] +fn single_line_objects_display_correctly() { + let expected = concat!( + "role: ACME Company\n", + "address: Packet Street 6\n", + "address: 128 Series of Tubes\n", + "address: Internet\n", + "email: rpsl-parser@github.com\n", + "nic-hdl: RPSL1-RIPE\n", + "source: RIPE\n", + "\n" + ); + + let borrowed = parse_object(expected).unwrap(); + let owned = object! { + "role": "ACME Company"; + "address": "Packet Street 6"; + "address": "128 Series of Tubes"; + "address": "Internet"; + "email": "rpsl-parser@github.com"; + "nic-hdl": "RPSL1-RIPE"; + "source": "RIPE"; + }; + + assert_eq!(borrowed.to_string(), expected); + assert_eq!(owned.to_string(), expected); +} + +#[test] +fn multi_line_objects_display_correctly() { + let expected = concat!( + "role: ACME Company\n", + "address: Packet Street 6\n", + " 128 Series of Tubes\n", + " Internet\n", + "email: rpsl-parser@github.com\n", + "nic-hdl: RPSL1-RIPE\n", + "source: RIPE\n", + "\n" + ); + + let borrowed = parse_object(expected).unwrap(); + let owned = object! { + "role": "ACME Company"; + "address": "Packet Street 6", "128 Series of Tubes", "Internet"; + "email": "rpsl-parser@github.com"; + "nic-hdl": "RPSL1-RIPE"; + "source": "RIPE"; + }; + + assert_eq!(borrowed.to_string(), expected); + assert_eq!(owned.to_string(), expected); +}