diff --git a/README.md b/README.md index e07c578..9a4b603 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,8 @@ The special characters have the following meaning: **Note:** the `L`, `W` and `#` special characters are described here for completeness but are **not implemented** by croncpp. An expression that uses one of them is rejected by `make_cron()` with a `bad_cronexpr` exception. +**Note:** an expression describing a date that never occurs, such as `0 0 5 31 2 ?` for the 31st of February, is also rejected by `make_cron()` with a `bad_cronexpr` exception, because it has no next occurrence to compute. February is measured as a leap year, so the 29th is accepted and the 30th is not. + Examples: | CRON | Description | diff --git a/include/croncpp.h b/include/croncpp.h index 8f597f1..45bd8ac 100644 --- a/include/croncpp.h +++ b/include/croncpp.h @@ -386,15 +386,43 @@ namespace cron } } + inline bool is_field_separator(char const ch) + { + return ch == ',' || ch == '-' || ch == '/'; + } + static std::string replace_ordinals( std::string text, std::vector const & replacement) { for (size_t i = 0; i < replacement.size(); ++i) { - auto pos = text.find(replacement[i]); - if (std::string::npos != pos) - text.replace(pos, 3 ,std::to_string(i)); + std::string const & name = replacement[i]; + if (name.empty()) continue; + + std::string const value = std::to_string(i); + + size_t pos = text.find(name); + while (std::string::npos != pos) + { + // Only a name standing on its own is a name. Replacing it + // wherever it appears turns "JAN1" into "11", which is a + // perfectly good month number and quietly means November. + size_t const end = pos + name.size(); + + bool const at_start = pos == 0 || is_field_separator(text[pos - 1]); + bool const at_end = end == text.size() || is_field_separator(text[end]); + + if (at_start && at_end) + { + text.replace(pos, name.size(), value); + pos = text.find(name, pos + value.size()); + } + else + { + pos = text.find(name, pos + 1); + } + } } return text; @@ -581,6 +609,35 @@ namespace cron return INVALID_INDEX; } + // Reports whether any day the expression asks for can occur in any month + // it asks for. February is measured as a leap year, so the 29th counts + // as reachable and the 30th does not. + // + // Both bitsets are indexed from the traits minimum, and every traits + // type numbers January and the first of the month as that minimum, so + // bit 0 is January and day 1 whatever the traits are. + inline bool has_reachable_date( + std::bitset<31> const & days_of_month, + std::bitset<12> const & months) + { + static int const last_day_of[12] = + { 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }; + + for (size_t month = 0; month < months.size(); ++month) + { + if (!months.test(month)) continue; + + for (size_t day = 0; day < days_of_month.size(); ++day) + { + if (days_of_month.test(day) && + static_cast(day) + 1 <= last_day_of[month]) + return true; + } + } + + return false; + } + inline int field_value( std::tm const & date, cron_field const field) @@ -986,6 +1043,12 @@ namespace cron detail::set_cron_month(fields[4], cex.months); + // A date such as the 31st of February never arrives, so there is no + // next occurrence to compute and the expression is rejected here rather + // than leaving the caller to make sense of a search that never succeeds. + if (!detail::has_reachable_date(cex.days_of_month, cex.months)) + throw bad_cronexpr("Date specified by the expression is invalid"); + cex.expr = expr; return cex; diff --git a/test/test_oracle.cpp b/test/test_oracle.cpp index a502177..a389312 100644 --- a/test/test_oracle.cpp +++ b/test/test_oracle.cpp @@ -114,6 +114,29 @@ TEST_CASE("oracle: invalid days of week", "[oracle]") CRON_EXPECT_EXCEPT("* * * * * 0-8"); } +TEST_CASE("oracle: month and day names have to stand on their own", "[oracle]") +{ + CRON_EXPECT_EXCEPT("0 0 0 1 JAN1 *"); + CRON_EXPECT_EXCEPT("* * * * * MON1"); + + // oracle numbers the months from 0, so JAN is 0 and DEC is 11 + CRON_ORCL_EQUAL("* * * * JAN *", "* * * * 0 *"); + CRON_ORCL_EQUAL("* * * * JAN,DEC *", "* * * * 0,11 *"); + CRON_ORCL_EQUAL("* * * * * MON-FRI", "* * * * * 2-6"); +} + +TEST_CASE("oracle: dates that never occur", "[oracle]") +{ + // oracle numbers the months 0 (January) to 11 (December), so February is 1 + CRON_EXPECT_EXCEPT("0 0 5 31 1 ?"); + CRON_EXPECT_EXCEPT("0 0 0 30 1 *"); + CRON_EXPECT_EXCEPT("0 0 0 31 3 *"); // April + + REQUIRE_NOTHROW(make_cron("0 0 0 29 1 *")); // leap day + REQUIRE_NOTHROW(make_cron("0 0 0 31 0 *")); // January + REQUIRE_NOTHROW(make_cron("0 0 0 31 2 *")); // March +} + TEST_CASE("oracle: every day of the week is reachable", "[oracle]") { // 2023-09-03 is a Sunday; oracle numbers the days 1 (SUN) to 7 (SAT) diff --git a/test/test_standard.cpp b/test/test_standard.cpp index 874ee4d..cc9f06e 100644 --- a/test/test_standard.cpp +++ b/test/test_standard.cpp @@ -376,6 +376,57 @@ TEST_CASE("standard: invalid days of week", "[std]") CRON_EXPECT_EXCEPT("* * * * * /2*-"); } +TEST_CASE("standard: month and day names have to stand on their own", "[std]") +{ + // "JAN1" used to be read as "11", which is a valid month number, so the + // expression was accepted and quietly meant November + CRON_EXPECT_EXCEPT("0 0 0 1 JAN1 *"); + CRON_EXPECT_EXCEPT("0 0 0 1 1JAN *"); + CRON_EXPECT_EXCEPT("0 0 0 1 JANJAN *"); + CRON_EXPECT_EXCEPT("0 0 0 1 JANUARY *"); + CRON_EXPECT_EXCEPT("0 0 0 1 XJANX *"); + CRON_EXPECT_EXCEPT("* * * * * MON1"); + CRON_EXPECT_EXCEPT("* * * * * 1MON"); + CRON_EXPECT_EXCEPT("* * * * * SUNDAY"); +} + +TEST_CASE("standard: names next to a separator are still names", "[std]") +{ + CRON_STD_EQUAL("* * * * JAN *", "* * * * 1 *"); + CRON_STD_EQUAL("* * * * JAN,FEB *", "* * * * 1,2 *"); + CRON_STD_EQUAL("* * * * JAN-MAR *", "* * * * 1-3 *"); + CRON_STD_EQUAL("* * * * JAN/2 *", "* * * * 1/2 *"); + CRON_STD_EQUAL("* * * * FEB-DEC/3 *", "* * * * 2-12/3 *"); + CRON_STD_EQUAL("* * * * * MON-FRI", "* * * * * 1-5"); + CRON_STD_EQUAL("* * * * * SUN,SAT", "* * * * * 0,6"); + + // a name repeated in a list is replaced every time, not just the first + CRON_STD_EQUAL("* * * * JAN,JAN *", "* * * * 1 *"); + CRON_STD_EQUAL("* * * * * MON,MON,FRI", "* * * * * 1,5"); +} + +TEST_CASE("standard: issue 7, dates that never occur", "[std]") +{ + CRON_EXPECT_EXCEPT("0 0 5 31 2 ?"); // February 31st + CRON_EXPECT_EXCEPT("0 0 0 30 2 *"); // February 30th + CRON_EXPECT_EXCEPT("0 0 0 31 4 *"); // April 31st + CRON_EXPECT_EXCEPT("0 0 0 31 6 *"); // June 31st + CRON_EXPECT_EXCEPT("0 0 0 31 9 *"); // September 31st + CRON_EXPECT_EXCEPT("0 0 0 31 11 *"); // November 31st + CRON_EXPECT_EXCEPT("0 0 0 30,31 2 *"); + CRON_EXPECT_EXCEPT("0 0 0 31 2,4,6 *"); +} + +TEST_CASE("standard: dates that do occur are still accepted", "[std]") +{ + REQUIRE_NOTHROW(make_cron("0 0 0 29 2 *")); // leap day + REQUIRE_NOTHROW(make_cron("0 0 0 31 1 *")); // January has 31 days + REQUIRE_NOTHROW(make_cron("0 0 0 31 2,3 *")); // March does too + REQUIRE_NOTHROW(make_cron("0 0 0 30 2,3 *")); + REQUIRE_NOTHROW(make_cron("0 0 0 31 * *")); + REQUIRE_NOTHROW(make_cron("0 0 0 * 2 *")); +} + TEST_CASE("standard: unsupported special characters", "[std]") { CRON_EXPECT_EXCEPT("0 0 12 L * ?");