Skip to content

Commit e6fc45c

Browse files
authored
Support capturing groups in Parameter regex (#204)
- fix `{string}` parameter returning enclosing quotes (cucumber-rs/cucumber-expressions#7)
1 parent 644abfc commit e6fc45c

File tree

14 files changed

+284
-46
lines changed

14 files changed

+284
-46
lines changed

CHANGELOG.md

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,27 @@ All user visible changes to `cucumber` crate will be documented in this file. Th
66

77

88

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

12-
[Diff](/../../compare/v0.11.3...v0.11.4) | [Milestone](/../../milestone/9)
12+
[Diff](/../../compare/v0.11.3...v0.12.0) | [Milestone](/../../milestone/9)
13+
14+
### BC Breaks
15+
16+
- `step::Context::matches` now has regex group name in addition to captured value. ([#204])
17+
18+
### Added
19+
20+
- Support for capturing groups in `Parameter` regex. ([#204], [cucumber-rs/cucumber-expressions#7])
1321

1422
### Fixed
1523

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

1826
[#200]: /../../issues/200
1927
[#202]: /../../pull/202
28+
[#204]: /../../pull/204
29+
[cucumber-rs/cucumber-expressions#7]: https://github.com/cucumber-rs/cucumber-expressions/issues/7
2030

2131

2232

Cargo.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "cucumber"
3-
version = "0.11.3"
3+
version = "0.12.0-dev"
44
edition = "2021"
55
rust-version = "1.57"
66
description = """\
@@ -53,8 +53,8 @@ regex = "1.5"
5353
sealed = "0.4"
5454

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

6060
# "output-json" feature dependencies.
29.1 KB
Loading

book/src/writing/capturing.md

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,7 @@ fn feed_cat(world: &mut AnimalWorld, times: u8) {
249249

250250
### Custom [parameters]
251251

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

254254
```rust
255255
# use std::{convert::Infallible, str::FromStr};
@@ -335,6 +335,96 @@ fn hungry_cat(world: &mut AnimalWorld, state: State) {
335335
336336
![record](../rec/writing_capturing_both.gif)
337337

338+
> __TIP__: In case [regex] of a [custom parameter][custom parameters] consists of several capturing groups, only the first non-empty match will be returned.
339+
340+
```rust
341+
# use std::{convert::Infallible, str::FromStr};
342+
#
343+
# use async_trait::async_trait;
344+
# use cucumber::{given, then, when, World, WorldInit};
345+
use cucumber::Parameter;
346+
347+
# #[derive(Debug)]
348+
# struct Cat {
349+
# pub hungry: Hungriness,
350+
# }
351+
#
352+
# impl Cat {
353+
# fn feed(&mut self) {
354+
# self.hungry = Hungriness::Satiated;
355+
# }
356+
# }
357+
#
358+
#[derive(Debug, Eq, Parameter, PartialEq)]
359+
#[param(regex = "(hungry)|(satiated)|'([^']*)'")]
360+
// We want to capture without quotes ^^^^^^^
361+
enum Hungriness {
362+
Hungry,
363+
Satiated,
364+
Other(String),
365+
}
366+
367+
// NOTE: `Parameter` requires `FromStr` being implemented.
368+
impl FromStr for Hungriness {
369+
type Err = String;
370+
371+
fn from_str(s: &str) -> Result<Self, Self::Err> {
372+
Ok(match s {
373+
"hungry" => Self::Hungry,
374+
"satiated" => Self::Satiated,
375+
other => Self::Other(other.to_owned()),
376+
})
377+
}
378+
}
379+
#
380+
# #[derive(Debug, WorldInit)]
381+
# pub struct AnimalWorld {
382+
# cat: Cat,
383+
# }
384+
#
385+
# #[async_trait(?Send)]
386+
# impl World for AnimalWorld {
387+
# type Error = Infallible;
388+
#
389+
# async fn new() -> Result<Self, Infallible> {
390+
# Ok(Self {
391+
# cat: Cat {
392+
# hungry: Hungriness::Satiated,
393+
# },
394+
# })
395+
# }
396+
# }
397+
398+
#[given(expr = "a {hungriness} cat")]
399+
fn hungry_cat(world: &mut AnimalWorld, hungry: Hungriness) {
400+
world.cat.hungry = hungry;
401+
}
402+
403+
#[then(expr = "the cat is {string}")]
404+
fn cat_is(world: &mut AnimalWorld, other: String) {
405+
assert_eq!(world.cat.hungry, Hungriness::Other(other));
406+
}
407+
#
408+
# #[when(expr = "I feed the cat {int} time(s)")]
409+
# fn feed_cat(world: &mut AnimalWorld, times: u8) {
410+
# for _ in 0..times {
411+
# world.cat.feed();
412+
# }
413+
# }
414+
#
415+
# #[then("the cat is not hungry")]
416+
# fn cat_is_fed(world: &mut AnimalWorld) {
417+
# assert_eq!(world.cat.hungry, Hungriness::Satiated);
418+
# }
419+
#
420+
# #[tokio::main]
421+
# async fn main() {
422+
# AnimalWorld::run("tests/features/book/writing/capturing_multiple_groups.feature").await;
423+
# }
424+
```
425+
426+
![record](../rec/writing_capturing_multiple_groups.gif)
427+
338428

339429

340430

book/tests/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ publish = false
1212
[dependencies]
1313
async-trait = "0.1"
1414
clap = { version = "3.0", features = ["derive"] }
15-
cucumber = { version = "0.11", path = "../..", features = ["output-json", "output-junit"] }
15+
cucumber = { version = "0.12.0-dev", path = "../..", features = ["output-json", "output-junit"] }
1616
futures = "0.3"
1717
humantime = "2.1"
1818
once_cell = { version = "1.8", features = ["parking_lot"] }

codegen/Cargo.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "cucumber-codegen"
3-
version = "0.11.3" # should be the same as main crate version
3+
version = "0.12.0-dev" # should be the same as main crate version
44
edition = "2021"
55
rust-version = "1.57"
66
description = "Code generation for `cucumber` crate."
@@ -21,7 +21,7 @@ exclude = ["/tests/"]
2121
proc-macro = true
2222

2323
[dependencies]
24-
cucumber-expressions = { version = "0.1", features = ["into-regex"] }
24+
cucumber-expressions = { version = "0.2", features = ["into-regex"] }
2525
inflections = "1.1"
2626
itertools = "0.10"
2727
proc-macro2 = "1.0.28"

codegen/src/attribute.rs

Lines changed: 94 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -213,18 +213,62 @@ impl Step {
213213
if is_regex_or_expr {
214214
if let Some(elem_ty) = find_first_slice(&func.sig) {
215215
let addon_parsing = Some(quote! {
216-
let __cucumber_matches = __cucumber_ctx
216+
let mut __cucumber_matches = ::std::vec::Vec::with_capacity(
217+
__cucumber_ctx.matches.len().saturating_sub(1),
218+
);
219+
let mut __cucumber_iter = __cucumber_ctx
217220
.matches
218221
.iter()
219222
.skip(1)
220-
.enumerate()
221-
.map(|(i, s)| {
223+
.enumerate();
224+
while let Some((i, (cap_name, s))) =
225+
__cucumber_iter.next()
226+
{
227+
// Special handling of `cucumber-expressions`
228+
// `parameter` with multiple capturing groups.
229+
let prefix = cap_name
230+
.as_ref()
231+
.filter(|n| n.starts_with("__"))
232+
.map(|n| {
233+
let num_len = n
234+
.chars()
235+
.skip(2)
236+
.take_while(|&c| c != '_')
237+
.map(char::len_utf8)
238+
.sum::<usize>();
239+
let len = num_len + b"__".len();
240+
n.split_at(len).0
241+
});
242+
243+
let to_take = __cucumber_iter
244+
.clone()
245+
.take_while(|(_, (n, _))| {
246+
prefix
247+
.zip(n.as_ref())
248+
.filter(|(prefix, n)| n.starts_with(prefix))
249+
.is_some()
250+
})
251+
.count();
252+
253+
let s = ::std::iter::once(s.as_str())
254+
.chain(
255+
__cucumber_iter
256+
.by_ref()
257+
.take(to_take)
258+
.map(|(_, (_, s))| s.as_str()),
259+
)
260+
.fold(None, |acc, s| {
261+
acc.or_else(|| (!s.is_empty()).then(|| s))
262+
})
263+
.unwrap_or_default();
264+
265+
__cucumber_matches.push(
222266
s.parse::<#elem_ty>().unwrap_or_else(|e| panic!(
223267
"Failed to parse element at {} '{}': {}",
224268
i, s, e,
225269
))
226-
})
227-
.collect::<Vec<_>>();
270+
);
271+
}
228272
});
229273
let func_args = func
230274
.sig
@@ -319,11 +363,49 @@ impl Step {
319363
);
320364

321365
quote! {
322-
let #ident = __cucumber_iter
323-
.next()
324-
.expect(#not_found_err)
325-
.parse::<#ty>()
326-
.expect(#parsing_err);
366+
let #ident = {
367+
let (cap_name, s) = __cucumber_iter
368+
.next()
369+
.expect(#not_found_err);
370+
// Special handling of `cucumber-expressions` `parameter`
371+
// with multiple capturing groups.
372+
let prefix = cap_name
373+
.as_ref()
374+
.filter(|n| n.starts_with("__"))
375+
.map(|n| {
376+
let num_len = n
377+
.chars()
378+
.skip(2)
379+
.take_while(|&c| c != '_')
380+
.map(char::len_utf8)
381+
.sum::<usize>();
382+
let len = num_len + b"__".len();
383+
n.split_at(len).0
384+
});
385+
386+
let to_take = __cucumber_iter
387+
.clone()
388+
.take_while(|(n, _)| {
389+
prefix.zip(n.as_ref())
390+
.filter(|(prefix, n)| n.starts_with(prefix))
391+
.is_some()
392+
})
393+
.count();
394+
395+
::std::iter::once(s.as_str())
396+
.chain(
397+
__cucumber_iter
398+
.by_ref()
399+
.take(to_take)
400+
.map(|(_, s)| s.as_str()),
401+
)
402+
.fold(
403+
None,
404+
|acc, s| acc.or_else(|| (!s.is_empty()).then(|| s)),
405+
)
406+
.unwrap_or_default()
407+
};
408+
let #ident = #ident.parse::<#ty>().expect(#parsing_err);
327409
}
328410
};
329411

@@ -533,7 +615,7 @@ impl<'p> Parameters<'p> {
533615
self.0
534616
.iter()
535617
.map(|par| {
536-
let name = par.param.0.fragment();
618+
let name = par.param.input.fragment();
537619
let ty = &par.ty;
538620

539621
if DEFAULT_PARAMETERS.contains(name) {
@@ -619,7 +701,7 @@ impl<'p> Parameters<'p> {
619701
.0
620702
.iter()
621703
.filter_map(|par| {
622-
let name = par.param.0.fragment();
704+
let name = par.param.input.fragment();
623705
(!DEFAULT_PARAMETERS.contains(name)).then(|| (*name, &par.ty))
624706
})
625707
.unzip();

codegen/src/lib.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,9 @@ steps!(given, when, then);
309309
///
310310
/// - `#[param(regex = "regex")]`
311311
///
312-
/// [`Regex`] to match this parameter. Shouldn't contain any capturing groups.
312+
/// [`Regex`] to match this parameter. Usually shouldn't contain any capturing
313+
/// groups, but in case it requires to do so, only the first non-empty group
314+
/// will be matched as the result.
313315
///
314316
/// - `#[param(name = "name")]` (optional)
315317
///

codegen/src/parameter.rs

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -68,12 +68,6 @@ impl TryFrom<syn::DeriveInput> for Definition {
6868
// TODO: Use "{e}" syntax once MSRV bumps above 1.58.
6969
syn::Error::new(attrs.regex.span(), format!("Invalid regex: {}", e))
7070
})?;
71-
if regex.captures_len() > 1 {
72-
return Err(syn::Error::new(
73-
attrs.regex.span(),
74-
"Regex shouldn't contain any capturing groups",
75-
));
76-
}
7771

7872
let name = attrs.name.as_ref().map_or_else(
7973
|| to_lower_case(&input.ident.to_string()),
@@ -156,6 +150,27 @@ mod spec {
156150
);
157151
}
158152

153+
#[test]
154+
fn derives_impl_with_capturing_group() {
155+
let input = parse_quote! {
156+
#[param(regex = "(cat)|(dog)")]
157+
struct Animal;
158+
};
159+
160+
let output = quote! {
161+
#[automatically_derived]
162+
impl ::cucumber::Parameter for Animal {
163+
const REGEX: &'static str = "(cat)|(dog)";
164+
const NAME: &'static str = "animal";
165+
}
166+
};
167+
168+
assert_eq!(
169+
super::derive(input).unwrap().to_string(),
170+
output.to_string(),
171+
);
172+
}
173+
159174
#[test]
160175
fn derives_impl_with_generics() {
161176
let input = parse_quote! {
@@ -215,17 +230,21 @@ mod spec {
215230
}
216231

217232
#[test]
218-
fn errors_on_capture_groups_in_regex() {
233+
fn invalid_regex() {
219234
let input = parse_quote! {
220-
#[param(regex = "(cat|dog)")]
235+
#[param(regex = "(cat|dog")]
221236
struct Parameter;
222237
};
223238

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

226241
assert_eq!(
227242
err.to_string(),
228-
"Regex shouldn't contain any capturing groups",
243+
"\
244+
Invalid regex: regex parse error:
245+
(cat|dog
246+
^
247+
error: unclosed group",
229248
);
230249
}
231250
}

0 commit comments

Comments
 (0)