use std::{
fmt::{Display, Formatter},
str::FromStr,
string::ToString,
};
use alpm_parsers::iter_char_context;
use serde::{Deserialize, Serialize};
use winnow::{
ModalResult,
Parser,
combinator::{Repeat, alt, cut_err, eof, peek, repeat, repeat_till},
error::{StrContext, StrContextValue},
stream::Stream,
token::{any, one_of, rest},
};
use crate::Error;
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub struct BuildTool(Name);
impl BuildTool {
pub fn new(name: Name) -> Self {
BuildTool(name)
}
pub fn new_with_restriction(name: &str, restrictions: &[Name]) -> Result<Self, Error> {
let buildtool = BuildTool::from_str(name)?;
if buildtool.matches_restriction(restrictions) {
Ok(buildtool)
} else {
Err(Error::ValueDoesNotMatchRestrictions {
restrictions: restrictions.iter().map(ToString::to_string).collect(),
})
}
}
pub fn matches_restriction(&self, restrictions: &[Name]) -> bool {
restrictions
.iter()
.any(|restriction| restriction.eq(self.inner()))
}
pub fn inner(&self) -> &Name {
&self.0
}
}
impl FromStr for BuildTool {
type Err = Error;
fn from_str(s: &str) -> Result<BuildTool, Self::Err> {
Name::new(s).map(BuildTool)
}
}
impl Display for BuildTool {
fn fmt(&self, fmt: &mut Formatter) -> std::fmt::Result {
write!(fmt, "{}", self.inner())
}
}
#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
pub struct Name(String);
impl Name {
pub fn new(name: &str) -> Result<Self, Error> {
Self::from_str(name)
}
pub fn inner(&self) -> &str {
&self.0
}
pub fn parser(input: &mut &str) -> ModalResult<Self> {
let alphanum = |c: char| c.is_ascii_alphanumeric();
let special_first_chars = ['_', '@', '+'];
let first_char = one_of((alphanum, special_first_chars))
.context(StrContext::Label("first character of package name"))
.context(StrContext::Expected(StrContextValue::Description(
"ASCII alphanumeric character",
)))
.context_with(iter_char_context!(special_first_chars));
let never_first_special_chars = ['_', '@', '+', '-', '.'];
let never_first_char = one_of((alphanum, never_first_special_chars));
let remaining_chars: Repeat<_, _, _, (), _> = repeat(0.., never_first_char);
let full_parser = (
first_char,
remaining_chars,
eof.context(StrContext::Label("character in package name"))
.context(StrContext::Expected(StrContextValue::Description(
"ASCII alphanumeric character",
)))
.context_with(iter_char_context!(never_first_special_chars)),
);
full_parser
.take()
.map(|n: &str| Name(n.to_owned()))
.parse_next(input)
}
}
impl FromStr for Name {
type Err = Error;
fn from_str(s: &str) -> Result<Name, Self::Err> {
Ok(Self::parser.parse(s)?)
}
}
impl Display for Name {
fn fmt(&self, fmt: &mut Formatter) -> std::fmt::Result {
write!(fmt, "{}", self.inner())
}
}
impl AsRef<str> for Name {
fn as_ref(&self) -> &str {
self.inner()
}
}
#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
pub struct SharedObjectName(pub(crate) Name);
impl SharedObjectName {
pub fn new(name: &str) -> Result<Self, Error> {
Self::from_str(name)
}
pub fn as_str(&self) -> &str {
self.0.as_ref()
}
pub fn parser(input: &mut &str) -> ModalResult<Self> {
let checkpoint = input.checkpoint();
repeat_till::<_, _, String, _, _, _, _>(1.., any, peek(alt((".so", eof))))
.context(StrContext::Label("name"))
.parse_next(input)?;
cut_err(repeat::<_, _, String, _, _>(1.., ".so").take())
.context(StrContext::Label("suffix"))
.context(StrContext::Expected(StrContextValue::Description(
"shared object name suffix '.so'",
)))
.parse_next(input)?;
cut_err(eof)
.context(StrContext::Label(
"unexpected trailing content after shared object name.",
))
.context(StrContext::Expected(StrContextValue::Description(
"end of input.",
)))
.parse_next(input)?;
input.reset(&checkpoint);
let name = rest
.and_then(Name::parser)
.context(StrContext::Label("name"))
.parse_next(input)?;
Ok(SharedObjectName(name))
}
}
impl FromStr for SharedObjectName {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(Self::parser.parse(s)?)
}
}
impl Display for SharedObjectName {
fn fmt(&self, fmt: &mut Formatter) -> std::fmt::Result {
write!(fmt, "{}", self.0)
}
}
#[cfg(test)]
mod tests {
use proptest::prelude::*;
use rstest::rstest;
use super::*;
#[rstest]
#[case(
"bar",
["foo".parse(), "bar".parse()].into_iter().flatten().collect::<Vec<Name>>(),
Ok(BuildTool::from_str("bar").unwrap()),
)]
#[case(
"bar",
["foo".parse(), "foo".parse()].into_iter().flatten().collect::<Vec<Name>>(),
Err(Error::ValueDoesNotMatchRestrictions {
restrictions: vec!["foo".to_string(), "foo".to_string()],
}),
)]
fn buildtool_new_with_restriction(
#[case] buildtool: &str,
#[case] restrictions: Vec<Name>,
#[case] result: Result<BuildTool, Error>,
) {
assert_eq!(
BuildTool::new_with_restriction(buildtool, &restrictions),
result
);
}
#[rstest]
#[case("bar", ["foo".parse(), "bar".parse()].into_iter().flatten().collect::<Vec<Name>>(), true)]
#[case("bar", ["foo".parse(), "foo".parse()].into_iter().flatten().collect::<Vec<Name>>(), false)]
fn buildtool_matches_restriction(
#[case] buildtool: &str,
#[case] restrictions: Vec<Name>,
#[case] result: bool,
) {
let buildtool = BuildTool::from_str(buildtool).unwrap();
assert_eq!(buildtool.matches_restriction(&restrictions), result);
}
#[rstest]
#[case("package_name_'''", "invalid character in package name")]
#[case("-package_with_leading_hyphen", "invalid first character")]
fn name_parse_error(#[case] input: &str, #[case] err_snippet: &str) {
let Err(Error::ParseError(err_msg)) = Name::from_str(input) else {
panic!("'{input}' erroneously parsed as a Name")
};
assert!(
err_msg.contains(err_snippet),
"Error:\n=====\n{err_msg}\n=====\nshould contain snippet:\n\n{err_snippet}"
);
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(1000))]
#[test]
fn valid_name_from_string(name_str in r"[a-zA-Z0-9_@+]+[a-zA-Z0-9\-._@+]*") {
let name = Name::from_str(&name_str).unwrap();
prop_assert_eq!(name_str, format!("{}", name));
}
#[test]
fn invalid_name_from_string_start(name_str in r"[-.][a-zA-Z0-9@._+-]*") {
let error = Name::from_str(&name_str).unwrap_err();
assert!(matches!(error, Error::ParseError(_)));
}
#[test]
fn invalid_name_with_invalid_characters(name_str in r"[^\w@._+-]+") {
let error = Name::from_str(&name_str).unwrap_err();
assert!(matches!(error, Error::ParseError(_)));
}
}
#[rstest]
#[case("example.so", SharedObjectName("example.so".parse().unwrap()))]
#[case("example.so.so", SharedObjectName("example.so.so".parse().unwrap()))]
#[case("libexample.1.so", SharedObjectName("libexample.1.so".parse().unwrap()))]
fn shared_object_name_parser(
#[case] input: &str,
#[case] expected_result: SharedObjectName,
) -> testresult::TestResult<()> {
let shared_object_name = SharedObjectName::new(input)?;
assert_eq!(expected_result, shared_object_name);
assert_eq!(input, shared_object_name.as_str());
Ok(())
}
#[rstest]
#[case("noso", "expected shared object name suffix '.so'")]
#[case("example.so.1", "unexpected trailing content after shared object name")]
fn invalid_shared_object_name_parser(#[case] input: &str, #[case] error_snippet: &str) {
let result = SharedObjectName::from_str(input);
assert!(result.is_err(), "Expected SharedObjectName parsing to fail");
let err = result.unwrap_err();
let pretty_error = err.to_string();
assert!(
pretty_error.contains(error_snippet),
"Error:\n=====\n{pretty_error}\n=====\nshould contain snippet:\n\n{error_snippet}"
);
}
}