Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add macros to construct libcnb-data newtypes from literal strings #172

Merged
merged 10 commits into from
Nov 19, 2021
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
- Add `PartialEq` and `Eq` implementations for `LayerTypes`.
- Add `LayerEnv::chainable_insert`
- `LayerEnv` and `ModificationBehavior` now implement `Clone`.
- Add `stack_id!`, `buildpack_id!` and `process_type!` macros.
- `Process::new` no longer returns a `Result` and it's `type` argument now is of type `ProcessType`.
- Made it easier to work with buildpack errors during all phases of a `LayerLifecycle`.
- `LayerEnv` was integrated into the `LayerLifecycle`, allowing buildpack authors to both write environment variables
in a declarative way and using them between different layers without explicit IO.
Expand Down
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
members = [
"libcnb",
"libcnb-data",
"libcnb-proc-macros",
"libcnb/examples/example-01-basics",
"libcnb/examples/example-02-ruby-sample"
]
3 changes: 2 additions & 1 deletion libcnb-data/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ include = ["src/**/*", "../LICENSE", "../README.md"]

[dependencies]
lazy_static = "^1.4.0"
regex = "^1.5.4"
fancy-regex = "^0.7.1"
semver = { version = "^1.0.4", features = ["serde"] }
serde = { version = "^1.0.126", features = ["derive"] }
thiserror = "^1.0.26"
toml = "^0.5.8"
libcnb-proc-macros = { path = "../libcnb-proc-macros", version = "0.1.0" }
33 changes: 29 additions & 4 deletions libcnb-data/src/buildpack.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use crate::newtypes::libcnb_newtype;
use fancy_regex::Regex;
use lazy_static::lazy_static;
use regex::Regex;
use semver::Version;
use serde::Deserialize;
use std::convert::TryFrom;
Expand Down Expand Up @@ -147,7 +147,7 @@ impl FromStr for BuildpackApi {
static ref RE: Regex = Regex::new(r"^(?P<major>\d+)(\.(?P<minor>\d+))?$").unwrap();
}

if let Some(captures) = RE.captures(value) {
if let Some(captures) = RE.captures(value).unwrap_or_default() {
if let Some(major) = captures.name("major") {
// these should never panic since we check with the regex unless it's greater than
// `std::u32::MAX`
Expand Down Expand Up @@ -178,6 +178,19 @@ impl Display for BuildpackApi {
}

libcnb_newtype!(
buildpack,
/// Construct a [`BuildpackId`] value at compile time.
///
/// Passing a string that is not a valid `BuildpackId` value will yield a compilation error.
///
/// # Examples:
/// ```
/// use libcnb_data::buildpack_id;
/// use libcnb_data::buildpack::BuildpackId;
///
/// let buildpack_id: BuildpackId = buildpack_id!("heroku/java");
/// ```
buildpack_id,
/// buildpack.toml Buildpack Id. This is a newtype wrapper around a String.
/// It MUST only contain numbers, letters, and the characters ., /, and -.
/// It also cannot be `config` or `app`.
Expand All @@ -196,11 +209,23 @@ libcnb_newtype!(
/// ```
BuildpackId,
BuildpackIdError,
r"^[[:alnum:]./-]+$",
|id| { id != "app" && id != "config" }
r"^(?!app$|config$)[[:alnum:]./-]+$"
);

libcnb_newtype!(
buildpack,
/// Construct a [`StackId`] value at compile time.
///
/// Passing a string that is not a valid `StackId` value will yield a compilation error.
///
/// # Examples:
/// ```
/// use libcnb_data::stack_id;
/// use libcnb_data::buildpack::StackId;
///
/// let stack_id: StackId = stack_id!("heroku-20");
/// ```
stack_id,
/// buildpack.toml Stack Id. This is a newtype wrapper around a String.
/// It MUST only contain numbers, letters, and the characters ., /, and -.
/// or be `*`.
Expand Down
8 changes: 8 additions & 0 deletions libcnb-data/src/internals.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// This macro is used by all newtype literal macros to verify if the value matches the regex. It
// is not intended to be used outside of this crate. But since the code that macros expand to is
// just regular code, we need to expose this to users of this crate.
//
// We cannot use `::libcnb_proc_macros::verify_regex` in our macros directly as this would require
// every crate to explicitly import the `libcnb_proc_macros` crate as crates can't use code from
// transitive dependencies.
pub use libcnb_proc_macros::verify_regex;
30 changes: 22 additions & 8 deletions libcnb-data/src/launch.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
use crate::bom;
use crate::newtypes::libcnb_newtype;
use serde::{Deserialize, Serialize};
use std::str::FromStr;

#[derive(Deserialize, Serialize, Debug)]
pub struct Launch {
Expand All @@ -20,9 +19,11 @@ pub struct Launch {
/// # Examples
/// ```
/// use libcnb_data::launch;
/// use libcnb_data::process_type;
///
/// let mut launch_toml = launch::Launch::new();
/// let web = launch::Process::new("web", "bundle", vec!["exec", "ruby", "app.rb"],
/// false, false).unwrap();
/// let web = launch::Process::new(process_type!("web"), "bundle", vec!["exec", "ruby", "app.rb"],
/// false, false);
///
/// launch_toml.processes.push(web);
/// assert!(toml::to_string(&launch_toml).is_ok());
Expand Down Expand Up @@ -67,19 +68,19 @@ pub struct Process {

impl Process {
pub fn new(
r#type: impl AsRef<str>,
r#type: ProcessType,
command: impl Into<String>,
args: impl IntoIterator<Item = impl Into<String>>,
direct: bool,
default: bool,
) -> Result<Self, ProcessTypeError> {
Ok(Self {
r#type: ProcessType::from_str(r#type.as_ref())?,
) -> Self {
Self {
r#type,
command: command.into(),
args: args.into_iter().map(std::convert::Into::into).collect(),
direct,
default,
})
}
}
}

Expand All @@ -89,6 +90,19 @@ pub struct Slice {
}

libcnb_newtype!(
launch,
/// Construct a [`ProcessType`] value at compile time.
///
/// Passing a string that is not a valid `ProcessType` value will yield a compilation error.
///
/// # Examples:
/// ```
/// use libcnb_data::launch::ProcessType;
/// use libcnb_data::process_type;
///
/// let process_type: ProcessType = process_type!("web");
/// ```
process_type,
/// launch.toml Process Type. This is a newtype wrapper around a String. It MUST only contain numbers, letters, and the characters ., _, and -. Use [`std::str::FromStr`] to create a new instance of this struct.
///
/// # Examples
Expand Down
4 changes: 4 additions & 0 deletions libcnb-data/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,7 @@ pub mod layer_content_metadata;
pub mod store;

mod newtypes;

// Internals that need to be public for macros
#[doc(hidden)]
pub mod internals;
108 changes: 62 additions & 46 deletions libcnb-data/src/newtypes.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/// Macro to generate newtypes backed by `String`
/// Macro to generate a newtype backed by `String` that is validated by a regular expression.
///
/// Automatically implements the following traits for the newtype:
/// - [`Debug`]
Expand All @@ -12,43 +12,45 @@
/// - [`Deref<Target=String>`]
/// - [`AsRef<String>`]
///
/// This macro also generates another macro that can be used to construct values of the newtype from
/// literal strings at compile time. Compilation will fail if such a macro is used with a string
/// that is not valid for the corresponding newtype. This removes the need for explicit `unwrap`
/// calls that might fail at runtime.
///
/// # Usage:
/// ```
/// libcnb_newtype!(
/// // The module of this crate that exports the newtype publicly. Since it might differ from
/// // the actual module structure, the macro needs a way to determine how to import the type
/// // from a user's buildpack crate.
/// tests::doctest
/// /// RustDoc for the macro (optional)
/// buildpack_id,
/// /// RustDoc for the newtype itself (optional)
/// BuildpackId,
/// /// RustDoc for the newtype error (optional)
/// BuildpackIdError,
/// // The regular expression that must match for the String to be valid
/// // The regular expression that must match for the String to be valid. Uses the `fancy_regex`
/// // crate which supports negative lookarounds.
/// r"^[[:alnum:]./-]+$",
/// // Additional predicate function to do further validation (optional)
/// |id| { id != "app" && id != "config" }
/// );
///
/// // Using the type:
/// let bp_id = "bar".parse::<BuildpackId>().unwrap();
///
/// // Using the macro for newtype literals with compile-type checks:
/// let bp_id = buildpack_id!("foo");
/// ```
macro_rules! libcnb_newtype {
(
$path:path,
$(#[$macro_attributes:meta])*
$macro_name:ident,
$(#[$type_attributes:meta])*
$name:ident,
$(#[$error_type_attributes:meta])*
$error_name:ident,
$regex:expr
) => {
libcnb_newtype!(
$(#[$type_attributes])*
$name,
$(#[$error_type_attributes])*
$error_name,
$regex,
|_| true
);
};
(
$(#[$type_attributes:meta])*
$name:ident,
$(#[$error_type_attributes:meta])*
$error_name:ident,
$regex:expr,
$extra_predicate:expr
) => {
#[derive(Debug, Eq, PartialEq, ::serde::Deserialize, ::serde::Serialize)]
$(#[$type_attributes])*
Expand All @@ -74,10 +76,11 @@ macro_rules! libcnb_newtype {
type Err = $error_name;

fn from_str(value: &str) -> Result<Self, Self::Err> {
let regex_matches = ::regex::Regex::new($regex).unwrap().is_match(value);
let predicate_matches = $extra_predicate(value);
let regex_matches = ::fancy_regex::Regex::new($regex)
.and_then(|regex| regex.is_match(value))
.unwrap_or(false);

if regex_matches && predicate_matches {
if regex_matches {
Ok(Self(String::from(value)))
} else {
Err($error_name::InvalidValue(String::from(value)))
Expand Down Expand Up @@ -110,6 +113,27 @@ macro_rules! libcnb_newtype {
::std::write!(f, "{}", self.0)
}
}

#[macro_export]
$(#[$macro_attributes])*
macro_rules! $macro_name {
($value:expr) => {
$crate::internals::verify_regex!(
$regex,
$value,
{
use $crate::$path as base;
$value.parse::<base::$name>().unwrap()
},
compile_error!(concat!(
stringify!($value),
" is not a valid ",
stringify!($name),
" value!"
))
)
}
}
};
}

Expand All @@ -119,28 +143,17 @@ pub(crate) use libcnb_newtype;
mod test {
use super::libcnb_newtype;

#[test]
fn test() {
libcnb_newtype!(CapitalizedName, CapitalizedNameError, r"^[A-Z][a-z]*$");

assert!("Manuel".parse::<CapitalizedName>().is_ok());

assert_eq!(
"manuel".parse::<CapitalizedName>(),
Err(CapitalizedNameError::InvalidValue(String::from("manuel")))
);
}
libcnb_newtype!(
newtypes::test,
capitalized_name,
CapitalizedName,
CapitalizedNameError,
r"^(?!Manuel$)[A-Z][a-z]*$"
);

#[test]
fn test_extra_predicate() {
libcnb_newtype!(
CapitalizedName,
CapitalizedNameError,
r"^[A-Z][a-z]*$",
|value| value != "Manuel"
);

assert!("Jonas".parse::<CapitalizedName>().is_ok());
fn test() {
assert!("Katrin".parse::<CapitalizedName>().is_ok());

assert_eq!(
"manuel".parse::<CapitalizedName>(),
Expand All @@ -154,9 +167,12 @@ mod test {
}

#[test]
fn test_deref() {
libcnb_newtype!(CapitalizedName, CapitalizedNameError, r"^[A-Z][a-z]*$");
fn test_literal_macro_success() {
assert_eq!("Jonas", capitalized_name!("Jonas").as_ref());
}

#[test]
fn test_deref() {
fn foo(name: &str) {
assert_eq!(name, "Johanna");
}
Expand Down
12 changes: 12 additions & 0 deletions libcnb-proc-macros/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
[package]
name = "libcnb-proc-macros"
Malax marked this conversation as resolved.
Show resolved Hide resolved
version = "0.1.0"
edition = "2021"

[lib]
proc-macro = true

[dependencies]
syn = {version = "1.0.81", features = ["full"]}
quote = "1.0.10"
fancy-regex = "0.7.1"
Loading