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

Support capturing groups in Parameter regex (cucumber-rs/cucumber-expressions#7) #204

Merged
merged 12 commits into from
Feb 10, 2022
Merged
16 changes: 13 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,27 @@ All user visible changes to `cucumber` crate will be documented in this file. Th



## [0.11.4] · 2022-02-??
[0.11.4]: /../../tree/v0.11.4
## [0.12.0] · 2022-02-??
[0.12.0]: /../../tree/v0.12.0

[Diff](/../../compare/v0.11.3...v0.11.4) | [Milestone](/../../milestone/9)
[Diff](/../../compare/v0.11.3...v0.12.0) | [Milestone](/../../milestone/9)

### BC Breaks

- `step::Context::matches` now has regex group name in addition to captured value. ([#204])

### Added

- Support for capturing groups in `Parameter` regex. ([#204], [cucumber-rs/cucumber-expressions#7])

### Fixed

- Book examples failing on Windows. ([#202], [#200])

[#200]: /../../issues/200
[#202]: /../../pull/202
[#204]: /../../pull/204
[cucumber-rs/cucumber-expressions#7]: https://github.com/cucumber-rs/cucumber-expressions/issues/7



Expand Down
6 changes: 3 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "cucumber"
version = "0.11.3"
version = "0.12.0-dev"
edition = "2021"
rust-version = "1.57"
description = """\
Expand Down Expand Up @@ -53,8 +53,8 @@ regex = "1.5"
sealed = "0.4"

# "macros" feature dependencies.
cucumber-codegen = { version = "0.11", path = "./codegen", optional = true }
cucumber-expressions = { version = "0.1", features = ["into-regex"], optional = true }
cucumber-codegen = { version = "0.12.0-dev", path = "./codegen", optional = true }
cucumber-expressions = { version = "0.2", features = ["into-regex"], optional = true }
inventory = { version = "0.2", optional = true }

# "output-json" feature dependencies.
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
92 changes: 91 additions & 1 deletion book/src/writing/capturing.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@ fn feed_cat(world: &mut AnimalWorld, times: u8) {

### Custom [parameters]

Another useful advantage of using [Cucumber Expressions][expr] is an ability to declare and reuse [custom parameters] in addition to [default ones][parameters].
Another useful advantage of using [Cucumber Expressions][expr] is an ability to declare and reuse [custom parameters] in addition to [default ones][parameters].

```rust
# use std::{convert::Infallible, str::FromStr};
Expand Down Expand Up @@ -335,6 +335,96 @@ fn hungry_cat(world: &mut AnimalWorld, state: State) {

![record](../rec/writing_capturing_both.gif)

> __TIP__: In case [regex] of a [custom parameter][custom parameters] consists of several capturing groups, only the first non-empty match will be returned.

```rust
# use std::{convert::Infallible, str::FromStr};
#
# use async_trait::async_trait;
# use cucumber::{given, then, when, World, WorldInit};
use cucumber::Parameter;

# #[derive(Debug)]
# struct Cat {
# pub hungry: Hungriness,
# }
#
# impl Cat {
# fn feed(&mut self) {
# self.hungry = Hungriness::Satiated;
# }
# }
#
#[derive(Debug, Eq, Parameter, PartialEq)]
#[param(regex = "(hungry)|(satiated)|'([^']*)'")]
// We want to capture without quotes ^^^^^^^
enum Hungriness {
Hungry,
Satiated,
Other(String),
}

// NOTE: `Parameter` requires `FromStr` being implemented.
impl FromStr for Hungriness {
type Err = String;

fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(match s {
"hungry" => Self::Hungry,
"satiated" => Self::Satiated,
other => Self::Other(other.to_owned()),
})
}
}
#
# #[derive(Debug, WorldInit)]
# pub struct AnimalWorld {
# cat: Cat,
# }
#
# #[async_trait(?Send)]
# impl World for AnimalWorld {
# type Error = Infallible;
#
# async fn new() -> Result<Self, Infallible> {
# Ok(Self {
# cat: Cat {
# hungry: Hungriness::Satiated,
# },
# })
# }
# }

#[given(expr = "a {hungriness} cat")]
fn hungry_cat(world: &mut AnimalWorld, hungry: Hungriness) {
world.cat.hungry = hungry;
}

#[then(expr = "the cat is {string}")]
fn cat_is(world: &mut AnimalWorld, other: String) {
assert_eq!(world.cat.hungry, Hungriness::Other(other));
}
#
# #[when(expr = "I feed the cat {int} time(s)")]
# fn feed_cat(world: &mut AnimalWorld, times: u8) {
# for _ in 0..times {
# world.cat.feed();
# }
# }
#
# #[then("the cat is not hungry")]
# fn cat_is_fed(world: &mut AnimalWorld) {
# assert_eq!(world.cat.hungry, Hungriness::Satiated);
# }
#
# #[tokio::main]
# async fn main() {
# AnimalWorld::run("tests/features/book/writing/capturing_multiple_groups.feature").await;
# }
```

![record](../rec/writing_capturing_multiple_groups.gif)




Expand Down
2 changes: 1 addition & 1 deletion book/tests/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ publish = false
[dependencies]
async-trait = "0.1"
clap = { version = "3.0", features = ["derive"] }
cucumber = { version = "0.11", path = "../..", features = ["output-json", "output-junit"] }
cucumber = { version = "0.12.0-dev", path = "../..", features = ["output-json", "output-junit"] }
futures = "0.3"
humantime = "2.1"
once_cell = { version = "1.8", features = ["parking_lot"] }
Expand Down
4 changes: 2 additions & 2 deletions codegen/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "cucumber-codegen"
version = "0.11.3" # should be the same as main crate version
version = "0.12.0-dev" # should be the same as main crate version
edition = "2021"
rust-version = "1.57"
description = "Code generation for `cucumber` crate."
Expand All @@ -21,7 +21,7 @@ exclude = ["/tests/"]
proc-macro = true

[dependencies]
cucumber-expressions = { version = "0.1", features = ["into-regex"] }
cucumber-expressions = { version = "0.2", features = ["into-regex"] }
inflections = "1.1"
itertools = "0.10"
proc-macro2 = "1.0.28"
Expand Down
106 changes: 94 additions & 12 deletions codegen/src/attribute.rs
Original file line number Diff line number Diff line change
Expand Up @@ -213,18 +213,62 @@ impl Step {
if is_regex_or_expr {
if let Some(elem_ty) = find_first_slice(&func.sig) {
let addon_parsing = Some(quote! {
let __cucumber_matches = __cucumber_ctx
let mut __cucumber_matches = ::std::vec::Vec::with_capacity(
__cucumber_ctx.matches.len().saturating_sub(1),
);
let mut __cucumber_iter = __cucumber_ctx
.matches
.iter()
.skip(1)
.enumerate()
.map(|(i, s)| {
.enumerate();
while let Some((i, (cap_name, s))) =
__cucumber_iter.next()
{
// Special handling of `cucumber-expressions`
// `parameter` with multiple capturing groups.
let prefix = cap_name
.as_ref()
.filter(|n| n.starts_with("__"))
.map(|n| {
let num_len = n
.chars()
.skip(2)
.take_while(|&c| c != '_')
.map(char::len_utf8)
.sum::<usize>();
let len = num_len + b"__".len();
n.split_at(len).0
});

let to_take = __cucumber_iter
.clone()
.take_while(|(_, (n, _))| {
prefix
.zip(n.as_ref())
.filter(|(prefix, n)| n.starts_with(prefix))
.is_some()
})
.count();

let s = ::std::iter::once(s.as_str())
.chain(
__cucumber_iter
.by_ref()
.take(to_take)
.map(|(_, (_, s))| s.as_str()),
)
.fold(None, |acc, s| {
acc.or_else(|| (!s.is_empty()).then(|| s))
})
.unwrap_or_default();

__cucumber_matches.push(
s.parse::<#elem_ty>().unwrap_or_else(|e| panic!(
"Failed to parse element at {} '{}': {}",
i, s, e,
))
})
.collect::<Vec<_>>();
);
}
});
let func_args = func
.sig
Expand Down Expand Up @@ -319,11 +363,49 @@ impl Step {
);

quote! {
let #ident = __cucumber_iter
.next()
.expect(#not_found_err)
.parse::<#ty>()
.expect(#parsing_err);
let #ident = {
let (cap_name, s) = __cucumber_iter
.next()
.expect(#not_found_err);
// Special handling of `cucumber-expressions` `parameter`
// with multiple capturing groups.
let prefix = cap_name
.as_ref()
.filter(|n| n.starts_with("__"))
.map(|n| {
let num_len = n
.chars()
.skip(2)
.take_while(|&c| c != '_')
.map(char::len_utf8)
.sum::<usize>();
let len = num_len + b"__".len();
n.split_at(len).0
});

let to_take = __cucumber_iter
.clone()
.take_while(|(n, _)| {
prefix.zip(n.as_ref())
.filter(|(prefix, n)| n.starts_with(prefix))
.is_some()
})
.count();

::std::iter::once(s.as_str())
.chain(
__cucumber_iter
.by_ref()
.take(to_take)
.map(|(_, s)| s.as_str()),
)
.fold(
None,
|acc, s| acc.or_else(|| (!s.is_empty()).then(|| s)),
)
.unwrap_or_default()
};
let #ident = #ident.parse::<#ty>().expect(#parsing_err);
}
};

Expand Down Expand Up @@ -533,7 +615,7 @@ impl<'p> Parameters<'p> {
self.0
.iter()
.map(|par| {
let name = par.param.0.fragment();
let name = par.param.input.fragment();
let ty = &par.ty;

if DEFAULT_PARAMETERS.contains(name) {
Expand Down Expand Up @@ -619,7 +701,7 @@ impl<'p> Parameters<'p> {
.0
.iter()
.filter_map(|par| {
let name = par.param.0.fragment();
let name = par.param.input.fragment();
(!DEFAULT_PARAMETERS.contains(name)).then(|| (*name, &par.ty))
})
.unzip();
Expand Down
4 changes: 3 additions & 1 deletion codegen/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,9 @@ steps!(given, when, then);
///
/// - `#[param(regex = "regex")]`
///
/// [`Regex`] to match this parameter. Shouldn't contain any capturing groups.
/// [`Regex`] to match this parameter. Usually shouldn't contain any capturing
/// groups, but in case it requires to do so, only the first non-empty group
/// will be matched as the result.
///
/// - `#[param(name = "name")]` (optional)
///
Expand Down
37 changes: 28 additions & 9 deletions codegen/src/parameter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,6 @@ impl TryFrom<syn::DeriveInput> for Definition {
// TODO: Use "{e}" syntax once MSRV bumps above 1.58.
syn::Error::new(attrs.regex.span(), format!("Invalid regex: {}", e))
})?;
if regex.captures_len() > 1 {
return Err(syn::Error::new(
attrs.regex.span(),
"Regex shouldn't contain any capturing groups",
));
}

let name = attrs.name.as_ref().map_or_else(
|| to_lower_case(&input.ident.to_string()),
Expand Down Expand Up @@ -156,6 +150,27 @@ mod spec {
);
}

#[test]
fn derives_impl_with_capturing_group() {
let input = parse_quote! {
#[param(regex = "(cat)|(dog)")]
struct Animal;
};

let output = quote! {
#[automatically_derived]
impl ::cucumber::Parameter for Animal {
const REGEX: &'static str = "(cat)|(dog)";
const NAME: &'static str = "animal";
}
};

assert_eq!(
super::derive(input).unwrap().to_string(),
output.to_string(),
);
}

#[test]
fn derives_impl_with_generics() {
let input = parse_quote! {
Expand Down Expand Up @@ -215,17 +230,21 @@ mod spec {
}

#[test]
fn errors_on_capture_groups_in_regex() {
fn invalid_regex() {
let input = parse_quote! {
#[param(regex = "(cat|dog)")]
#[param(regex = "(cat|dog")]
struct Parameter;
};

let err = super::derive(input).unwrap_err();

assert_eq!(
err.to_string(),
"Regex shouldn't contain any capturing groups",
"\
Invalid regex: regex parse error:
(cat|dog
^
error: unclosed group",
);
}
}
Loading