Skip to content

Commit

Permalink
Add macros to construct libcnb-data newtypes from literal strings (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
Malax authored Nov 19, 2021
1 parent a66f1f9 commit 9c6843f
Show file tree
Hide file tree
Showing 12 changed files with 223 additions and 65 deletions.
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"
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

0 comments on commit 9c6843f

Please sign in to comment.