use std::{
fmt::{Display, Formatter},
str::FromStr,
};
use serde::{Deserialize, Serialize};
use winnow::{
ModalResult,
Parser,
ascii::space1,
combinator::{alt, cut_err, eof, opt, separated_pair, seq, terminated},
error::{StrContext, StrContextValue},
token::{rest, take_till, take_until},
};
use crate::{Error, Name, VersionRequirement};
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct PackageRelation {
pub name: Name,
pub version_requirement: Option<VersionRequirement>,
}
impl PackageRelation {
pub fn new(name: Name, version_requirement: Option<VersionRequirement>) -> Self {
Self {
name,
version_requirement,
}
}
pub fn parser(input: &mut &str) -> ModalResult<Self> {
seq!(Self {
name: take_till(1.., ('<', '>', '=')).and_then(Name::parser).context(StrContext::Label("package name")),
version_requirement: opt(VersionRequirement::parser),
_: eof.context(StrContext::Expected(StrContextValue::Description("end of relation version requirement"))),
})
.parse_next(input)
}
}
impl Display for PackageRelation {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
if let Some(version_requirement) = self.version_requirement.as_ref() {
write!(f, "{}{}", self.name, version_requirement)
} else {
write!(f, "{}", self.name)
}
}
}
impl FromStr for PackageRelation {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(Self::parser.parse(s)?)
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct OptionalDependency {
package_relation: PackageRelation,
description: Option<String>,
}
impl OptionalDependency {
pub fn new(
package_relation: PackageRelation,
description: Option<String>,
) -> OptionalDependency {
OptionalDependency {
package_relation,
description,
}
}
pub fn name(&self) -> &Name {
&self.package_relation.name
}
pub fn version_requirement(&self) -> &Option<VersionRequirement> {
&self.package_relation.version_requirement
}
pub fn description(&self) -> &Option<String> {
&self.description
}
pub fn package_relation(&self) -> &PackageRelation {
&self.package_relation
}
pub fn parser(input: &mut &str) -> ModalResult<Self> {
let description_parser = terminated(
take_till(0.., ('\n', '\r')),
eof,
)
.context(StrContext::Label("optional dependency description"))
.context(StrContext::Expected(StrContextValue::Description(
r"no carriage returns or newlines",
)))
.map(|d: &str| match d.trim_ascii() {
"" => None,
t => Some(t.to_string()),
});
let (package_relation, description) = alt((
separated_pair(
take_until(1.., ":").and_then(cut_err(PackageRelation::parser)),
(":", space1),
rest.and_then(cut_err(description_parser)),
),
(rest.and_then(PackageRelation::parser), eof.value(None)),
))
.parse_next(input)?;
Ok(Self {
package_relation,
description,
})
}
}
impl FromStr for OptionalDependency {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(Self::parser.parse(s)?)
}
}
impl Display for OptionalDependency {
fn fmt(&self, fmt: &mut Formatter) -> std::fmt::Result {
match self.description {
Some(ref description) => write!(fmt, "{}: {}", self.package_relation, description),
None => write!(fmt, "{}", self.package_relation),
}
}
}
pub type Group = String;
#[cfg(test)]
mod tests {
use proptest::{prop_assert_eq, proptest, test_runner::Config as ProptestConfig};
use rstest::rstest;
use super::*;
use crate::VersionComparison;
const COMPARATOR_REGEX: &str = r"(<|<=|=|>=|>)";
const EPOCH_REGEX: &str = r"[1-9]{1}[0-9]{0,10}";
const NAME_REGEX: &str = r"[a-z0-9_@+]+[a-z0-9\-._@+]*";
const PKGREL_REGEX: &str = r"[1-9][0-9]{0,8}(|[.][1-9][0-9]{0,8})";
const PKGVER_REGEX: &str = r"([[:alnum:]][[:alnum:]_+.]*)";
const DESCRIPTION_REGEX: &str = "[^\n\r]*";
proptest! {
#![proptest_config(ProptestConfig::with_cases(1000))]
#[test]
fn valid_package_relation_from_str(s in format!("{NAME_REGEX}(|{COMPARATOR_REGEX}(|{EPOCH_REGEX}:){PKGVER_REGEX}(|-{PKGREL_REGEX}))").as_str()) {
println!("s: {s}");
let name = PackageRelation::from_str(&s).unwrap();
prop_assert_eq!(s, format!("{}", name));
}
}
proptest! {
#[test]
fn opt_depend_from_str(
name in NAME_REGEX,
desc in DESCRIPTION_REGEX,
use_desc in proptest::bool::ANY
) {
let desc_trimmed = desc.trim_ascii();
let desc_is_blank = desc_trimmed.is_empty();
let (raw_in, formatted_expected) = if use_desc {
// Raw input and expected formatted output.
// These are different because `desc` will be trimmed by the parser;
// if it is *only* ascii whitespace then it will be skipped altogether.
(
format!("{name}: {desc}"),
if !desc_is_blank {
format!("{name}: {desc_trimmed}")
} else {
name.clone()
}
)
} else {
(name.clone(), name.clone())
};
println!("input string: {raw_in}");
let opt_depend = OptionalDependency::from_str(&raw_in).unwrap();
let formatted_actual = format!("{opt_depend}");
prop_assert_eq!(
formatted_expected,
formatted_actual,
"Formatted output doesn't match input"
);
}
}
#[rstest]
#[case(
"python>=3",
Ok(PackageRelation {
name: Name::new("python").unwrap(),
version_requirement: Some(VersionRequirement {
comparison: VersionComparison::GreaterOrEqual,
version: "3".parse().unwrap(),
}),
}),
)]
#[case(
"java-environment>=17",
Ok(PackageRelation {
name: Name::new("java-environment").unwrap(),
version_requirement: Some(VersionRequirement {
comparison: VersionComparison::GreaterOrEqual,
version: "17".parse().unwrap(),
}),
}),
)]
fn valid_package_relation(
#[case] input: &str,
#[case] expected: Result<PackageRelation, Error>,
) {
assert_eq!(PackageRelation::from_str(input), expected);
}
#[rstest]
#[case(
"example: this is an example dependency",
OptionalDependency {
package_relation: PackageRelation {
name: Name::new("example").unwrap(),
version_requirement: None,
},
description: Some("this is an example dependency".to_string()),
},
)]
#[case(
"example-two: a description with lots of whitespace padding ",
OptionalDependency {
package_relation: PackageRelation {
name: Name::new("example-two").unwrap(),
version_requirement: None,
},
description: Some("a description with lots of whitespace padding".to_string())
},
)]
#[case(
"dep_name",
OptionalDependency {
package_relation: PackageRelation {
name: Name::new("dep_name").unwrap(),
version_requirement: None,
},
description: None,
},
)]
#[case(
"dep_name: ",
OptionalDependency {
package_relation: PackageRelation {
name: Name::new("dep_name").unwrap(),
version_requirement: None,
},
description: None,
},
)]
#[case(
"dep_name_with_special_chars-123: description with !@#$%^&*",
OptionalDependency {
package_relation: PackageRelation {
name: Name::new("dep_name_with_special_chars-123").unwrap(),
version_requirement: None,
},
description: Some("description with !@#$%^&*".to_string()),
},
)]
// versioned optional dependencies
#[case(
"elfutils=0.192: for translations",
OptionalDependency {
package_relation: PackageRelation {
name: Name::new("elfutils").unwrap(),
version_requirement: Some(VersionRequirement {
comparison: VersionComparison::Equal,
version: "0.192".parse().unwrap(),
}),
},
description: Some("for translations".to_string()),
},
)]
#[case(
"python>=3: For Python bindings",
OptionalDependency {
package_relation: PackageRelation {
name: Name::new("python").unwrap(),
version_requirement: Some(VersionRequirement {
comparison: VersionComparison::GreaterOrEqual,
version: "3".parse().unwrap(),
}),
},
description: Some("For Python bindings".to_string()),
},
)]
#[case(
"java-environment>=17: required by extension-wiki-publisher and extension-nlpsolver",
OptionalDependency {
package_relation: PackageRelation {
name: Name::new("java-environment").unwrap(),
version_requirement: Some(VersionRequirement {
comparison: VersionComparison::GreaterOrEqual,
version: "17".parse().unwrap(),
}),
},
description: Some("required by extension-wiki-publisher and extension-nlpsolver".to_string()),
},
)]
fn opt_depend_from_string(#[case] input: &str, #[case] expected: OptionalDependency) {
let opt_depend_result = OptionalDependency::from_str(input);
let Ok(optional_dependency) = opt_depend_result else {
panic!(
"Encountered unexpected error when parsing optional dependency: {opt_depend_result:?}"
)
};
assert_eq!(
expected, optional_dependency,
"Optional dependency has not been correctly parsed."
);
}
#[rstest]
#[case(
"example: this is an example dependency",
"example: this is an example dependency"
)]
#[case(
"example-two: a description with lots of whitespace padding ",
"example-two: a description with lots of whitespace padding"
)]
#[case(
"tabs: a description with a tab directly after the colon",
"tabs: a description with a tab directly after the colon"
)]
#[case("dep_name", "dep_name")]
#[case("dep_name: ", "dep_name")]
#[case(
"dep_name_with_special_chars-123: description with !@#$%^&*",
"dep_name_with_special_chars-123: description with !@#$%^&*"
)]
// versioned optional dependencies
#[case("elfutils=0.192: for translations", "elfutils=0.192: for translations")]
#[case("python>=3: For Python bindings", "python>=3: For Python bindings")]
#[case(
"java-environment>=17: required by extension-wiki-publisher and extension-nlpsolver",
"java-environment>=17: required by extension-wiki-publisher and extension-nlpsolver"
)]
fn opt_depend_to_string(#[case] input: &str, #[case] expected: &str) {
let opt_depend_result = OptionalDependency::from_str(input);
let Ok(optional_dependency) = opt_depend_result else {
panic!(
"Encountered unexpected error when parsing optional dependency: {opt_depend_result:?}"
)
};
assert_eq!(
expected,
optional_dependency.to_string(),
"OptionalDependency to_string is erroneous."
);
}
#[rstest]
#[case(
"#invalid-name: this is an example dependency",
"invalid first character of package name"
)]
#[case(": no_name_colon", "invalid first character of package name")]
#[case(
"name:description with no leading whitespace",
"invalid character in package name"
)]
#[case(
"dep-name>=10: \n\ndescription with\rnewlines",
"expected no carriage returns or newlines"
)]
fn opt_depend_invalid_string_parse_error(#[case] input: &str, #[case] err_snippet: &str) {
let Err(Error::ParseError(err_msg)) = OptionalDependency::from_str(input) else {
panic!("'{input}' did not fail to parse as expected")
};
assert!(
err_msg.contains(err_snippet),
"Error:\n=====\n{err_msg}\n=====\nshould contain snippet:\n\n{err_snippet}"
);
}
}