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 Cucumber Expressions (#124) #157

Merged
merged 8 commits into from
Nov 23, 2021
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,15 @@ All user visible changes to `cucumber` crate will be documented in this file. Th
- `writer::Normalized` trait required for `Writer`s in `Cucumber` running methods. ([#162])
- `writer::NonTransforming` trait required for `writer::Repeat`. ([#162])
- `writer::Summarizable` trait required for `writer::Summarize`. ([#162])
- Support for [Cucumber Expressions] via `#[given(expr = ...)]`, `#[when(expr = ...)]` and `#[then(expr = ...)]` syntax. ([#157])

### Fixed

- Template regex in `Scenario Outline` expansion from `<(\S+)>` to `<([^>\s]+)>`. ([#163])

[#147]: /../../pull/147
[#151]: /../../pull/151
[#157]: /../../pull/157
[#159]: /../../pull/159
[#160]: /../../pull/160
[#162]: /../../pull/162
Expand Down Expand Up @@ -263,4 +265,5 @@ All user visible changes to `cucumber` crate will be documented in this file. Th

[`gherkin_rust`]: https://docs.rs/gherkin_rust

[Cucumber Expressions]: https://cucumber.github.io/cucumber-expressions
[Semantic Versioning 2.0.0]: https://semver.org
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ impl cucumber::World for World {
}
}

#[given(regex = r"^(\S+) is hungry$")]
#[given(expr = "{word} is hungry")]
async fn someone_is_hungry(w: &mut World, user: String) {
sleep(Duration::from_secs(2)).await;

Expand All @@ -69,7 +69,7 @@ async fn eat_cucumbers(w: &mut World, count: usize) {
assert!(w.capacity < 4, "{} exploded!", w.user.as_ref().unwrap());
}

#[then(regex = r"^(?:he|she|they) (?:is|are) full$")]
#[then(expr = "he/she/they is/are full")]
async fn is_full(w: &mut World) {
sleep(Duration::from_secs(2)).await;

Expand Down
4 changes: 2 additions & 2 deletions book/src/Features.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ async fn cat_is_fed(world: &mut AnimalWorld) {
<script id="asciicast-o1s4mSMYkkVBy4WAsG8lhYtT8" src="https://asciinema.org/a/o1s4mSMYkkVBy4WAsG8lhYtT8.js" async data-autoplay="true" data-rows="18"></script>


### Combining `regex` and `FromStr`
### Combining `regex`/`cucumber-expressions` and `FromStr`

At parsing stage, `<templates>` are replaced by value from cells. That means you can parse table cells into any type, that implements [`FromStr`](https://doc.rust-lang.org/stable/std/str/trait.FromStr.html).

Expand Down Expand Up @@ -262,7 +262,7 @@ async fn feed_cat(world: &mut AnimalWorld, times: usize) {
}
}

#[then(regex = r"^the (\S+) is not hungry$")]
#[then(expr = "the {word} is not hungry")]
async fn cat_is_fed(world: &mut AnimalWorld) {
sleep(Duration::from_secs(2)).await;

Expand Down
59 changes: 59 additions & 0 deletions book/src/Getting_Started.md
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,64 @@ We surround regex with `^..$` to ensure the __exact__ match. This is much more u

Captured groups are __bold__ to indicate which part of step could be dynamically changed.

Alternatively, you may use [Cucumber Expressions] for the same purpose (less powerful, but much more readable):
```rust
# use std::convert::Infallible;
#
# use async_trait::async_trait;
# use cucumber::{given, then, when, World, WorldInit};
#
# #[derive(Debug)]
# struct Cat {
# pub hungry: bool,
# }
#
# impl Cat {
# fn feed(&mut self) {
# self.hungry = false;
# }
# }
#
# #[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: false },
# })
# }
# }
#
#[given(expr = "a {word} cat")]
fn hungry_cat(world: &mut AnimalWorld, state: String) {
match state.as_str() {
"hungry" => world.cat.hungry = true,
"satiated" => world.cat.hungry = false,
s => panic!("expected 'hungry' or 'satiated', found: {}", s),
}
}
#
# #[when("I feed the cat")]
# fn feed_cat(world: &mut AnimalWorld) {
# world.cat.feed();
# }
#
# #[then("the cat is not hungry")]
# fn cat_is_fed(world: &mut AnimalWorld) {
# assert!(!world.cat.hungry);
# }
#
# fn main() {
# futures::executor::block_on(AnimalWorld::run("/tests/features/book"));
# }
```

A contrived example, but this demonstrates that steps can be reused as long as they are sufficiently precise in both their description and implementation. If, for example, the wording for our `Then` step was `The cat is no longer hungry`, it'd imply something about the expected initial state, when that is not the purpose of a `Then` step, but rather of the `Given` step.

<details>
Expand Down Expand Up @@ -540,4 +598,5 @@ Feature: Animal feature


[Cucumber]: https://cucumber.io
[Cucumber Expressions]: https://cucumber.github.io/cucumber-expressions
[Gherkin]: https://cucumber.io/docs/gherkin/reference
2 changes: 1 addition & 1 deletion book/src/Test_Modules_Organization.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Of course, how you group your step definitions is really up to you and your team

## Avoid duplication

Avoid writing similar step definitions, as they can lead to clutter. While documenting your steps helps, making use of [`regex` and `FromStr`](Features.md#combining-regex-and-fromstr) can do wonders.
Avoid writing similar step definitions, as they can lead to clutter. While documenting your steps helps, making use of [`regex`/`cucumber-expressions` and `FromStr`](Features.md#combining-regexcucumber-expressions-and-fromstr) can do wonders.



Expand Down
3 changes: 3 additions & 0 deletions codegen/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ All user visible changes to `cucumber-codegen` crate will be documented in this
### Added

- Unwrapping `Result`s returned by step functions. ([#151])
- `expr = ...` argument to `#[given(...)]`, `#[when(...)]` and `#[then(...)]` attributes allowing [Cucumber Expressions]. ([#157])

[#151]: /../../pull/151
[#157]: /../../pull/157



Expand Down Expand Up @@ -76,4 +78,5 @@ See `cucumber` crate [changelog](https://github.com/cucumber-rs/cucumber/blob/v0



[Cucumber Expressions]: https://cucumber.github.io/cucumber-expressions
[Semantic Versioning 2.0.0]: https://semver.org
1 change: 1 addition & 0 deletions codegen/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ exclude = ["/tests/"]
proc-macro = true

[dependencies]
cucumber-expressions = { version = "0.1", features = ["into-regex"] }
inflections = "1.1"
itertools = "0.10"
proc-macro2 = "1.0.28"
Expand Down
69 changes: 50 additions & 19 deletions codegen/src/attribute.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

use std::mem;

use cucumber_expressions::Expression;
use inflections::case::to_pascal_case;
use proc_macro2::TokenStream;
use quote::{format_ident, quote};
Expand Down Expand Up @@ -199,10 +200,13 @@ impl Step {
fn fn_arguments_and_additional_parsing(
&self,
) -> syn::Result<(TokenStream, Option<TokenStream>)> {
let is_regex = matches!(self.attr_arg, AttributeArgument::Regex(_));
let is_regex_or_expr = matches!(
self.attr_arg,
AttributeArgument::Regex(_) | AttributeArgument::Expression(_),
);
let func = &self.func;

if is_regex {
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
Expand Down Expand Up @@ -351,6 +355,9 @@ enum AttributeArgument {

/// `#[step(regex = "regex")]` case.
Regex(syn::LitStr),

/// `#[step(expr = "cucumber-expression")]` case.
Expression(syn::LitStr),
}

impl AttributeArgument {
Expand All @@ -359,7 +366,7 @@ impl AttributeArgument {
/// [`syn::LitStr`]: struct@syn::LitStr
fn regex_literal(&self) -> syn::LitStr {
match self {
Self::Regex(l) => l.clone(),
Self::Regex(l) | Self::Expression(l) => l.clone(),
Self::Literal(l) => syn::LitStr::new(
&format!("^{}$", regex::escape(&l.value())),
l.span(),
Expand All @@ -373,29 +380,53 @@ impl Parse for AttributeArgument {
let arg = input.parse::<syn::NestedMeta>()?;
match arg {
syn::NestedMeta::Meta(syn::Meta::NameValue(arg)) => {
if arg.path.is_ident("regex") {
let str_lit = to_string_literal(arg.lit)?;

drop(Regex::new(str_lit.value().as_str()).map_err(
|e| {
syn::Error::new(
str_lit.span(),
format!("Invalid regex: {}", e),
)
},
)?);

Ok(Self::Regex(str_lit))
} else {
Err(syn::Error::new(arg.span(), "Expected regex argument"))
match arg.path.get_ident() {
Some(i) if i == "regex" => {
let str_lit = to_string_literal(arg.lit)?;

drop(Regex::new(str_lit.value().as_str()).map_err(
|e| {
syn::Error::new(
str_lit.span(),
format!("Invalid regex: {}", e),
)
},
)?);

Ok(Self::Regex(str_lit))
}
Some(i) if i == "expr" => {
let str_lit = to_string_literal(arg.lit)?;

let expr_regex =
Expression::regex(str_lit.value().as_str())
.map_err(|e| {
syn::Error::new(
str_lit.span(),
format!(
"Invalid cucumber expression: {}",
e,
),
)
})?;

Ok(Self::Expression(syn::LitStr::new(
expr_regex.as_str(),
str_lit.span(),
)))
}
_ => Err(syn::Error::new(
arg.span(),
"Expected `regex` or `expr` argument",
)),
}
}

syn::NestedMeta::Lit(l) => Ok(Self::Literal(to_string_literal(l)?)),

syn::NestedMeta::Meta(_) => Err(syn::Error::new(
arg.span(),
"Expected string literal or regex argument",
"Expected string literal, `regex` or `expr` argument",
)),
}
}
Expand Down
24 changes: 22 additions & 2 deletions codegen/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ macro_rules! step_attribute {
/// # use std::{convert::Infallible};
/// #
/// # use async_trait::async_trait;
/// use cucumber::{given, World, WorldInit};
/// use cucumber::{given, when, World, WorldInit};
///
/// #[derive(Debug, WorldInit)]
/// struct MyWorld;
Expand All @@ -130,6 +130,7 @@ macro_rules! step_attribute {
/// }
///
/// #[given(regex = r"(\S+) is (\d+)")]
/// #[when(expr = "{word} is {int}")]
/// fn test(w: &mut MyWorld, param: String, num: i32) {
/// assert_eq!(param, "foo");
/// assert_eq!(num, 0);
Expand All @@ -141,7 +142,24 @@ macro_rules! step_attribute {
/// }
/// ```
///
/// # Arguments
/// # Attribute arguments
///
/// - `#[given(regex = "regex")]`
///
/// Uses [`Regex`] for matching the step. [`Regex`] is checked at
/// compile time to have valid syntax.
///
/// - `#[given(expr = "cucumber-expression")]`
///
/// Uses [Cucumber Expression][1] for matching the step. It's checked
/// at compile time to have valid syntax.
///
/// - `#[given("literal")]`
///
/// Matches the step with an **exact** literal only. Doesn't allow any
/// values capturing to use as function arguments.
///
/// # Function arguments
///
/// - First argument has to be mutable reference to the [`WorldInit`]
/// deriver (your [`World`] implementer).
Expand Down Expand Up @@ -193,8 +211,10 @@ macro_rules! step_attribute {
///
/// [`Display`]: std::fmt::Display
/// [`FromStr`]: std::str::FromStr
/// [`Regex`]: regex::Regex
/// [`gherkin::Step`]: https://bit.ly/3j42hcd
/// [`World`]: https://bit.ly/3j0aWw7
/// [1]: cucumber_expressions
#[proc_macro_attribute]
pub fn $name(args: TokenStream, input: TokenStream) -> TokenStream {
attribute::step(std::stringify!($name), args.into(), input.into())
Expand Down
10 changes: 5 additions & 5 deletions codegen/tests/example.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ async fn test_non_regex_async(w: &mut MyWorld, #[step] ctx: &Step) {
}

#[given(regex = r"(\S+) is (\d+)")]
#[when(regex = r"(\S+) is (\d+)")]
#[when(expr = r"{word} is {int}")]
async fn test_regex_async(
w: &mut MyWorld,
step: String,
Expand All @@ -64,7 +64,7 @@ fn test_regex_sync_slice(w: &mut MyWorld, step: &Step, matches: &[String]) {
w.foo += 1;
}

#[when(regex = r#"^I write "(\S+)" to `([^`\s]+)`$"#)]
#[when(regex = r#"^I write "(\S+)" to '([^'\s]+)'$"#)]
fn test_return_result_write(
w: &mut MyWorld,
what: String,
Expand All @@ -75,16 +75,16 @@ fn test_return_result_write(
fs::write(path, what)
}

#[then(regex = r#"^the file `([^`\s]+)` should contain "(\S+)"$"#)]
#[then(expr = "the file {string} should contain {string}")]
fn test_return_result_read(
w: &mut MyWorld,
filename: String,
what: String,
) -> io::Result<()> {
let mut path = w.dir.path().to_path_buf();
path.push(filename);
path.push(filename.trim_matches('\''));

assert_eq!(what, fs::read_to_string(path)?);
assert_eq!(what.trim_matches('"'), fs::read_to_string(path)?);

Ok(())
}
Expand Down
8 changes: 4 additions & 4 deletions codegen/tests/features/example.feature
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ Feature: Example feature
Given foo is sync 0

Scenario: Steps returning result
When I write "abc" to `myfile.txt`
Then the file `myfile.txt` should contain "abc"
When I write "abc" to 'myfile.txt'
Then the file 'myfile.txt' should contain "abc"

Scenario: Steps returning result and failing
When I write "abc" to `myfile.txt`
Then the file `not-here.txt` should contain "abc"
When I write "abc" to 'myfile.txt'
Then the file 'not-here.txt' should contain "abc"