diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f729275..e6cea8bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ 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 @@ -36,6 +37,7 @@ All user visible changes to `cucumber` crate will be documented in this file. Th [#147]: /../../pull/147 [#151]: /../../pull/151 +[#157]: /../../pull/157 [#159]: /../../pull/159 [#160]: /../../pull/160 [#162]: /../../pull/162 @@ -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 diff --git a/README.md b/README.md index e8174e04..c090f678 100644 --- a/README.md +++ b/README.md @@ -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; @@ -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; diff --git a/book/src/Features.md b/book/src/Features.md index 5723e80e..6e47ac4b 100644 --- a/book/src/Features.md +++ b/book/src/Features.md @@ -173,7 +173,7 @@ async fn cat_is_fed(world: &mut AnimalWorld) { -### Combining `regex` and `FromStr` +### Combining `regex`/`cucumber-expressions` and `FromStr` At parsing stage, `` 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). @@ -194,38 +194,42 @@ Feature: Animal feature ```rust # use std::{convert::Infallible, str::FromStr, time::Duration}; -# +# # use async_trait::async_trait; # use cucumber::{given, then, when, World, WorldInit}; # use tokio::time::sleep; -# -# #[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 { -# Ok(Self { -# cat: Cat { hungry: false }, -# }) -# } -# } -# +# +#[derive(Debug)] +struct AnimalState { + pub hungry: bool +} + +impl AnimalState { + fn feed(&mut self) { + self.hungry = false; + } +} + +#[derive(Debug, WorldInit)] +pub struct AnimalWorld { + cat: AnimalState, + dog: AnimalState, + ferris: AnimalState, +} + +#[async_trait(?Send)] +impl World for AnimalWorld { + type Error = Infallible; + + async fn new() -> Result { + Ok(Self { + cat: AnimalState { hungry: false }, + dog: AnimalState { hungry: false }, + ferris: AnimalState { hungry: false }, + }) + } +} + enum State { Hungry, Satiated, @@ -243,32 +247,65 @@ impl FromStr for State { } } +enum Animal { + Cat, + Dog, + Ferris, +} + +impl FromStr for Animal { + type Err = &'static str; + + fn from_str(s: &str) -> Result { + match s { + "cat" => Ok(Self::Cat), + "dog" => Ok(Self::Dog), + "🦀" => Ok(Self::Ferris), + _ => Err("expected 'cat', 'dog' or '🦀'"), + } + } +} + #[given(regex = r"^a (\S+) (\S+)$")] -async fn hungry_cat(world: &mut AnimalWorld, state: State) { +async fn hungry_cat(world: &mut AnimalWorld, state: State, animal: Animal) { sleep(Duration::from_secs(2)).await; - match state { - State::Hungry => world.cat.hungry = true, - State::Satiated => world.cat.hungry = false, - } + let hunger = match state { + State::Hungry => true, + State::Satiated => false, + }; + + match animal { + Animal::Cat => world.cat.hungry = hunger, + Animal::Dog => world.dog.hungry = hunger, + Animal::Ferris => world.ferris.hungry = hunger, + }; } -#[when(regex = r"^I feed the (?:\S+) (\d+) times?$")] -async fn feed_cat(world: &mut AnimalWorld, times: usize) { +#[when(regex = r"^I feed the (\S+) (\d+) times?$")] +async fn feed_cat(world: &mut AnimalWorld, animal: Animal, times: usize) { sleep(Duration::from_secs(2)).await; for _ in 0..times { - world.cat.feed(); + match animal { + Animal::Cat => world.cat.feed(), + Animal::Dog => world.dog.feed(), + Animal::Ferris => world.ferris.feed(), + }; } } -#[then(regex = r"^the (\S+) is not hungry$")] -async fn cat_is_fed(world: &mut AnimalWorld) { +#[then(expr = "the {word} is not hungry")] +async fn cat_is_fed(world: &mut AnimalWorld, animal: Animal) { sleep(Duration::from_secs(2)).await; - assert!(!world.cat.hungry); + match animal { + Animal::Cat => assert!(!world.cat.hungry), + Animal::Dog => assert!(!world.dog.hungry), + Animal::Ferris => assert!(!world.ferris.hungry), + }; } -# +# # #[tokio::main] # async fn main() { # AnimalWorld::run("/tests/features/book/features/scenario_outline_fromstr.feature").await; diff --git a/book/src/Getting_Started.md b/book/src/Getting_Started.md index e560fa10..207976a3 100644 --- a/book/src/Getting_Started.md +++ b/book/src/Getting_Started.md @@ -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 { +# 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.
@@ -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 diff --git a/book/src/Test_Modules_Organization.md b/book/src/Test_Modules_Organization.md index 0f2f6a6a..69b7271d 100644 --- a/book/src/Test_Modules_Organization.md +++ b/book/src/Test_Modules_Organization.md @@ -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. diff --git a/codegen/CHANGELOG.md b/codegen/CHANGELOG.md index da23d6cd..938cf7a4 100644 --- a/codegen/CHANGELOG.md +++ b/codegen/CHANGELOG.md @@ -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 @@ -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 diff --git a/codegen/Cargo.toml b/codegen/Cargo.toml index 2281830d..5cc6db09 100644 --- a/codegen/Cargo.toml +++ b/codegen/Cargo.toml @@ -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" diff --git a/codegen/src/attribute.rs b/codegen/src/attribute.rs index c9c9e3d5..b14b2a40 100644 --- a/codegen/src/attribute.rs +++ b/codegen/src/attribute.rs @@ -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}; @@ -199,10 +200,13 @@ impl Step { fn fn_arguments_and_additional_parsing( &self, ) -> syn::Result<(TokenStream, Option)> { - 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 @@ -351,6 +355,9 @@ enum AttributeArgument { /// `#[step(regex = "regex")]` case. Regex(syn::LitStr), + + /// `#[step(expr = "cucumber-expression")]` case. + Expression(syn::LitStr), } impl AttributeArgument { @@ -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(), @@ -373,21 +380,45 @@ impl Parse for AttributeArgument { let arg = input.parse::()?; 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", + )), } } @@ -395,7 +426,7 @@ impl Parse for AttributeArgument { syn::NestedMeta::Meta(_) => Err(syn::Error::new( arg.span(), - "Expected string literal or regex argument", + "Expected string literal, `regex` or `expr` argument", )), } } diff --git a/codegen/src/lib.rs b/codegen/src/lib.rs index 4b339b06..228ab203 100644 --- a/codegen/src/lib.rs +++ b/codegen/src/lib.rs @@ -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; @@ -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); @@ -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). @@ -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()) diff --git a/codegen/tests/example.rs b/codegen/tests/example.rs index aa592e7c..d23c59bc 100644 --- a/codegen/tests/example.rs +++ b/codegen/tests/example.rs @@ -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, @@ -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, @@ -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(()) } diff --git a/codegen/tests/features/example.feature b/codegen/tests/features/example.feature index 9ee37183..b1c9fcc5 100644 --- a/codegen/tests/features/example.feature +++ b/codegen/tests/features/example.feature @@ -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"