diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d21a8f..d2db8a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ - `cuke::current_feature()`, `cuke::current_scenario()` and `cuke::current_step()` to access the feature/scenario/step currently being executed from hooks or step definitions ([125](https://github.com/ThoSe1990/cwt-cucumber/pull/125)) +### Fixed + +- Literal parentheses `()` and curly braces `{}` in step text are not matched; escape them with `\(`, `\)`, `\{`, `\}` to use them literally instead of as optional text or a parameter type ([129](https://github.com/ThoSe1990/cwt-cucumber/pull/129)) + ## [2.9] 2026-06-26 ### Added diff --git a/examples/step_definition.cpp b/examples/step_definition.cpp index 81309d8..23fa0cf 100644 --- a/examples/step_definition.cpp +++ b/examples/step_definition.cpp @@ -18,6 +18,13 @@ WHEN(add_item, "I place {int} x {string} in it") cuke::context().add_items(item, count); } +WHEN(coords, "I have {int},{int} as coordinates") +{ + const int x = CUKE_ARG(1); + const int y = CUKE_ARG(2); + + std::cout << "given coordinates x=" << x << " y=" << y << '\n'; +} WHEN(add_table_raw, "I add all items with the raw function:") { const cuke::table& t = CUKE_TABLE(); diff --git a/gtest/step_finder.cc b/gtest/step_finder.cc index baaed4f..81d76bb 100644 --- a/gtest/step_finder.cc +++ b/gtest/step_finder.cc @@ -207,6 +207,17 @@ TEST(step_finder, multiple_values) EXPECT_EQ(sf.values().at(5).as(), 6); } +TEST(step_finder, ints_in_parenthesis) +{ + auto [pattern, types] = + create_regex_definition("these are \\({int},{int}\\) coordinates"); + step_finder sf("these are (1,2) coordinates"); + ASSERT_TRUE(sf.step_matches(pattern)); + EXPECT_EQ(sf.values().size(), 2); + EXPECT_EQ(sf.values().at(0).as(), 1); + EXPECT_EQ(sf.values().at(1).as(), 2); +} + TEST(step_finder, word_at_stepend) { auto [pattern, types] = create_regex_definition("step with {word}"); @@ -495,6 +506,156 @@ TEST(step_finder, step_alternation_2) } } +TEST(step_finder, escaped_literal_parenthesis) +{ + auto [pattern, types] = + create_regex_definition("this is a literal \\(parenthesis\\)"); + step_finder sf("this is a literal (parenthesis)"); + EXPECT_TRUE(sf.step_matches(pattern)); +} + +TEST(step_finder, escaped_parenthesis_do_not_match_without_them) +{ + auto [pattern, types] = + create_regex_definition("this is a literal \\(parenthesis\\)"); + step_finder sf("this is a literal parenthesis"); + EXPECT_FALSE(sf.step_matches(pattern)); +} + +TEST(step_finder, mixed_escaped_and_optional_parenthesis) +{ + auto [pattern, types] = + create_regex_definition("these are \\({int},{int}\\) coordinate(s)"); + { + step_finder sf("these are (1,2) coordinate"); + ASSERT_TRUE(sf.step_matches(pattern)); + ASSERT_EQ(sf.values().size(), 2); + EXPECT_EQ(sf.values().at(0).as(), 1); + EXPECT_EQ(sf.values().at(1).as(), 2); + } + { + step_finder sf("these are (3,4) coordinates"); + ASSERT_TRUE(sf.step_matches(pattern)); + ASSERT_EQ(sf.values().size(), 2); + EXPECT_EQ(sf.values().at(0).as(), 3); + EXPECT_EQ(sf.values().at(1).as(), 4); + } +} + +TEST(step_finder, escaped_literal_curly_braces) +{ + auto [pattern, types] = + create_regex_definition("I see \\{foo\\} in the output"); + step_finder sf("I see {foo} in the output"); + EXPECT_TRUE(sf.step_matches(pattern)); +} + +TEST(step_finder, escaped_curly_braces_do_not_match_without_them) +{ + auto [pattern, types] = + create_regex_definition("I see \\{foo\\} in the output"); + step_finder sf("I see foo in the output"); + EXPECT_FALSE(sf.step_matches(pattern)); +} + +TEST(step_finder, escaped_curly_braces_are_not_treated_as_a_type) +{ + auto [pattern, types] = create_regex_definition("value is \\{5\\}"); + step_finder sf("value is {5}"); + ASSERT_TRUE(sf.step_matches(pattern)); + EXPECT_EQ(sf.values().size(), 0); +} + +TEST(step_finder, mixed_escaped_braces_and_real_type) +{ + auto [pattern, types] = + create_regex_definition("I see \\{foo\\} and {int} items"); + step_finder sf("I see {foo} and 3 items"); + ASSERT_TRUE(sf.step_matches(pattern)); + ASSERT_EQ(sf.values().size(), 1); + EXPECT_EQ(sf.values().at(0).as(), 3); +} + +TEST(step_finder, escaped_brace_wraps_a_real_type) +{ + auto [pattern, types] = create_regex_definition("value is \\{{int}\\}"); + step_finder sf("value is {5}"); + ASSERT_TRUE(sf.step_matches(pattern)); + ASSERT_EQ(sf.values().size(), 1); + EXPECT_EQ(sf.values().at(0).as(), 5); +} + +TEST(step_finder, multiple_separate_escaped_parenthesis_groups) +{ + auto [pattern, types] = create_regex_definition("\\(a\\) and \\(b\\)"); + step_finder sf("(a) and (b)"); + EXPECT_TRUE(sf.step_matches(pattern)); +} + +TEST(step_finder, consecutive_escaped_parens_and_braces) +{ + auto [pattern, types] = create_regex_definition("\\(\\)\\{\\}"); + step_finder sf("(){}"); + EXPECT_TRUE(sf.step_matches(pattern)); +} + +TEST(step_finder, escaped_parenthesis_at_string_start_and_end) +{ + auto [pattern, types] = create_regex_definition("\\(start and end\\)"); + { + step_finder sf("(start and end)"); + EXPECT_TRUE(sf.step_matches(pattern)); + } + { + step_finder sf("start and end"); + EXPECT_FALSE(sf.step_matches(pattern)); + } +} + +TEST(step_finder, escaped_parenthesis_next_to_slash_alternation) +{ + auto [pattern, types] = create_regex_definition("\\(cat/dog\\) is a pet"); + { + step_finder sf("(cat) is a pet"); + EXPECT_TRUE(sf.step_matches(pattern)); + } + { + step_finder sf("(dog) is a pet"); + EXPECT_TRUE(sf.step_matches(pattern)); + } + { + step_finder sf("(cat/dog) is a pet"); + EXPECT_FALSE(sf.step_matches(pattern)); + } +} + +TEST(step_finder, escaped_brace_combined_with_optional_text) +{ + auto [pattern, types] = + create_regex_definition("The result(s) show \\{status\\}"); + { + step_finder sf("The result show {status}"); + EXPECT_TRUE(sf.step_matches(pattern)); + } + { + step_finder sf("The results show {status}"); + EXPECT_TRUE(sf.step_matches(pattern)); + } + { + // missing literal braces must not match + step_finder sf("The result show status"); + EXPECT_FALSE(sf.step_matches(pattern)); + } +} + +TEST(step_finder, unrelated_backslash_is_still_escaped_normally) +{ + auto [pattern, types] = + create_regex_definition(add_escape_chars(R"(The path is C:\Users)")); + step_finder sf(R"(The path is C:\Users)"); + EXPECT_TRUE(sf.step_matches(pattern)); +} + class custom_types : public ::testing::Test { protected: diff --git a/src/util_regex.hpp b/src/util_regex.hpp index 4092a02..39a1095 100644 --- a/src/util_regex.hpp +++ b/src/util_regex.hpp @@ -34,10 +34,34 @@ namespace cuke::internal [[nodiscard]] static std::string create_word_alternation( const std::string& step) { - std::string result = step; + std::string result; + result.reserve(step.size()); - result = std::regex_replace(result, std::regex("\\)"), ")?"); - result = std::regex_replace(result, std::regex("\\("), "(?:"); + for (std::size_t i = 0; i < step.size(); ++i) + { + const char c = step[i]; + + if (c == '\\' && i + 1 < step.size() && + (step[i + 1] == '(' || step[i + 1] == ')' || step[i + 1] == '{' || + step[i + 1] == '}')) + { + result += '\\'; + result += step[i + 1]; + ++i; + } + else if (c == '(') + { + result += "(?:"; + } + else if (c == ')') + { + result += ")?"; + } + else + { + result += c; + } + } std::regex pattern("(\\w+)/(\\w+)"); std::smatch match; @@ -91,8 +115,20 @@ static std::string add_escape_chars(const std::string& input) /* '|' */}; std::string result; - for (char c : input) + for (std::size_t i = 0; i < input.size(); ++i) { + const char c = input[i]; + + if (c == '\\' && i + 1 < input.size() && + (input[i + 1] == '(' || input[i + 1] == ')' || input[i + 1] == '{' || + input[i + 1] == '}')) + { + result += c; + result += input[i + 1]; + ++i; + continue; + } + if (special_chars.find(c) != special_chars.end()) { result += '\\'; diff --git a/stress-tests/CMakeLists.txt b/stress-tests/CMakeLists.txt index e739e0e..20f5036 100644 --- a/stress-tests/CMakeLists.txt +++ b/stress-tests/CMakeLists.txt @@ -3,6 +3,7 @@ set(target stress-tests) add_executable(${target} ${CMAKE_CURRENT_SOURCE_DIR}/step_definition.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/hooks.cpp ) install( TARGETS ${target} diff --git a/stress-tests/features/stress-tests.feature b/stress-tests/features/stress-tests.feature index 7c9e897..ed446e5 100644 --- a/stress-tests/features/stress-tests.feature +++ b/stress-tests/features/stress-tests.feature @@ -67,3 +67,23 @@ Feature: Stress tests | val1 | val2 | val3 | | | "" | | | | "" | | + + Scenario: Literal parenthesis in step text + When There is a point at (3,4) + Then The point should be at coordinates 3 and 4 + + Scenario: Literal parenthesis in step text + When There is a point at (-3,-4) + Then The point should be at coordinates -3 and -4 + + Scenario: Singular optional text + When I have 1 item in stock + Then The stock count should be 1 + + Scenario: Plural optional text + When I have 5 items in stock + Then The stock count should be 5 + + Scenario: Literal curly braces in step text + When I see {status} in the raw output + Then The captured text should be "{status}" diff --git a/stress-tests/step_definition.cpp b/stress-tests/step_definition.cpp index 8c305c6..b13804a 100644 --- a/stress-tests/step_definition.cpp +++ b/stress-tests/step_definition.cpp @@ -70,3 +70,46 @@ WHEN(empty_cells_in_examples, "Some values {word}, {} and {string} are empty") const std::string string_value = CUKE_ARG(3); cuke::is_true(string_value.empty()); } + +struct point +{ + int x = 0; + int y = 0; +}; + +WHEN(point_at, "There is a point at \\({int},{int}\\)") +{ + cuke::context().x = CUKE_ARG(1); + cuke::context().y = CUKE_ARG(2); +} + +THEN(point_check, "The point should be at coordinates {int} and {int}") +{ + const int expected_x = CUKE_ARG(1); + const int expected_y = CUKE_ARG(2); + + cuke::equal(expected_x, cuke::context().x); + cuke::equal(expected_y, cuke::context().y); +} + +WHEN(items_in_stock, "I have {int} item(s) in stock") +{ + cuke::context() = CUKE_ARG(1); +} + +THEN(stock_count_check, "The stock count should be {int}") +{ + const int expected = CUKE_ARG(1); + cuke::equal(expected, cuke::context()); +} + +WHEN(raw_output, "I see \\{status\\} in the raw output") +{ + cuke::context() = "{status}"; +} + +THEN(raw_output_check, "The captured text should be {string}") +{ + const std::string expected = CUKE_ARG(1); + cuke::equal(expected, cuke::context()); +}