diff --git a/src/core/time/CMakeLists.txt b/src/core/time/CMakeLists.txt index 0b001806b..71c05020b 100644 --- a/src/core/time/CMakeLists.txt +++ b/src/core/time/CMakeLists.txt @@ -1,4 +1,5 @@ -sourcemeta_library(NAMESPACE sourcemeta PROJECT core NAME time SOURCES gmt.cc) +sourcemeta_library(NAMESPACE sourcemeta PROJECT core NAME time + SOURCES gmt.cc datetime.cc) if(SOURCEMETA_CORE_INSTALL) sourcemeta_library_install(NAMESPACE sourcemeta PROJECT core NAME time) diff --git a/src/core/time/datetime.cc b/src/core/time/datetime.cc new file mode 100644 index 000000000..bea9e295a --- /dev/null +++ b/src/core/time/datetime.cc @@ -0,0 +1,208 @@ +#include + +#include // std::array + +namespace sourcemeta::core { + +static constexpr auto is_digit(const char character) -> bool { + return character >= '0' && character <= '9'; +} + +static constexpr auto is_leap_year(const unsigned int year) -> bool { + return (year % 4 == 0) && (year % 100 != 0 || year % 400 == 0); +} + +static constexpr auto max_day_in_month(const unsigned int month, + const unsigned int year) + -> unsigned int { + constexpr std::array days{ + {0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}}; + if (month == 2 && is_leap_year(year)) { + return 29; + } + return days[month]; +} + +auto is_datetime(const std::string_view value) -> bool { + const auto size{value.size()}; + + // Minimum valid date-time: "YYYY-MM-DDTHH:MM:SSZ" = 20 characters + if (size < 20) { + return false; + } + + std::string_view::size_type position{0}; + + // --- full-date: date-fullyear "-" date-month "-" date-mday --- + + // date-fullyear = 4DIGIT + if (!is_digit(value[0]) || !is_digit(value[1]) || !is_digit(value[2]) || + !is_digit(value[3])) { + return false; + } + const auto year{static_cast(value[0] - '0') * 1000 + + static_cast(value[1] - '0') * 100 + + static_cast(value[2] - '0') * 10 + + static_cast(value[3] - '0')}; + position = 4; + + // "-" + if (value[position] != '-') { + return false; + } + position += 1; + + // date-month = 2DIGIT ; 01-12 + if (!is_digit(value[position]) || !is_digit(value[position + 1])) { + return false; + } + const auto month{static_cast(value[position] - '0') * 10 + + static_cast(value[position + 1] - '0')}; + if (month < 1 || month > 12) { + return false; + } + position += 2; + + // "-" + if (value[position] != '-') { + return false; + } + position += 1; + + // date-mday = 2DIGIT ; 01-28/29/30/31 based on month/year + if (!is_digit(value[position]) || !is_digit(value[position + 1])) { + return false; + } + const auto day{static_cast(value[position] - '0') * 10 + + static_cast(value[position + 1] - '0')}; + position += 2; + + // --- "T" or "t" separator --- + if (value[position] != 'T' && value[position] != 't') { + return false; + } + position += 1; + + // --- partial-time: time-hour ":" time-minute ":" time-second --- + + // time-hour = 2DIGIT ; 00-23 + if (!is_digit(value[position]) || !is_digit(value[position + 1])) { + return false; + } + const auto hour{static_cast(value[position] - '0') * 10 + + static_cast(value[position + 1] - '0')}; + if (hour > 23) { + return false; + } + position += 2; + + // ":" + if (value[position] != ':') { + return false; + } + position += 1; + + // time-minute = 2DIGIT ; 00-59 + if (!is_digit(value[position]) || !is_digit(value[position + 1])) { + return false; + } + const auto minute{static_cast(value[position] - '0') * 10 + + static_cast(value[position + 1] - '0')}; + if (minute > 59) { + return false; + } + position += 2; + + // ":" + if (value[position] != ':') { + return false; + } + position += 1; + + // time-second = 2DIGIT ; 00-60 (60 = leap second per §5.7) + if (!is_digit(value[position]) || !is_digit(value[position + 1])) { + return false; + } + const auto second{static_cast(value[position] - '0') * 10 + + static_cast(value[position + 1] - '0')}; + if (second > 60) { + return false; + } + position += 2; + + // --- [time-secfrac] = "." 1*DIGIT --- + if (position < size && value[position] == '.') { + position += 1; + if (position >= size || !is_digit(value[position])) { + // "." must be followed by at least 1 digit + return false; + } + while (position < size && is_digit(value[position])) { + position += 1; + } + } + + // --- time-offset = "Z" / time-numoffset --- + if (position >= size) { + // No time offset present — invalid + return false; + } + + if (value[position] == 'Z' || value[position] == 'z') { + position += 1; + } else if (value[position] == '+' || value[position] == '-') { + position += 1; + + // time-numoffset = ("+" / "-") time-hour ":" time-minute + if (position + 5 > size) { + return false; + } + + // Offset time-hour = 2DIGIT ; 00-23 + if (!is_digit(value[position]) || !is_digit(value[position + 1])) { + return false; + } + const auto offset_hour{ + static_cast(value[position] - '0') * 10 + + static_cast(value[position + 1] - '0')}; + if (offset_hour > 23) { + return false; + } + position += 2; + + // ":" — REQUIRED (colonless offsets like +0530 are invalid per §5.6) + if (value[position] != ':') { + return false; + } + position += 1; + + // Offset time-minute = 2DIGIT ; 00-59 + if (!is_digit(value[position]) || !is_digit(value[position + 1])) { + return false; + } + const auto offset_minute{ + static_cast(value[position] - '0') * 10 + + static_cast(value[position + 1] - '0')}; + if (offset_minute > 59) { + return false; + } + position += 2; + } else { + // Not Z, not +/-, invalid character for time-offset + return false; + } + + // String must be fully consumed — no trailing characters + if (position != size) { + return false; + } + + // --- Validate date-mday against month/year (§5.7) --- + if (day < 1 || day > max_day_in_month(month, year)) { + return false; + } + + return true; +} + +} // namespace sourcemeta::core diff --git a/src/core/time/include/sourcemeta/core/time.h b/src/core/time/include/sourcemeta/core/time.h index e14a2094e..aa623a014 100644 --- a/src/core/time/include/sourcemeta/core/time.h +++ b/src/core/time/include/sourcemeta/core/time.h @@ -5,12 +5,13 @@ #include #endif -#include // std::chrono::system_clock::time_point -#include // std::string +#include // std::chrono::system_clock::time_point +#include // std::string +#include // std::string_view /// @defgroup time Time -/// @brief A growing implementation of time-related utilities for standard such -/// as RFC 7231 (GMT). +/// @brief A growing implementation of time-related utilities for standards +/// such as RFC 7231 (GMT) and RFC 3339 (Internet Date/Time Format). /// /// This functionality is included as follows: /// @@ -79,6 +80,31 @@ auto to_gmt(const std::chrono::system_clock::time_point time) -> std::string; SOURCEMETA_CORE_TIME_EXPORT auto from_gmt(const std::string &time) -> std::chrono::system_clock::time_point; +/// @ingroup time +/// Check whether the given string is a valid date-time value per RFC 3339 +/// Section 5.6 (Internet Date/Time Format). This implements the full +/// `date-time` production rule: +/// +/// ``` +/// date-time = full-date "T" full-time +/// ``` +/// +/// where "T" may also be lowercase "t" (per RFC 3339 §5.6 NOTE). For example: +/// +/// ```cpp +/// #include +/// +/// #include +/// +/// assert(sourcemeta::core::is_datetime("1985-04-12T23:20:50.52Z")); +/// assert(sourcemeta::core::is_datetime("1996-12-19T16:39:57-08:00")); +/// assert(sourcemeta::core::is_datetime("1990-12-31T23:59:60Z")); +/// assert(!sourcemeta::core::is_datetime("2024-01-15T14:30:00")); +/// assert(!sourcemeta::core::is_datetime("2024-01-15 14:30:00Z")); +/// ``` +SOURCEMETA_CORE_TIME_EXPORT +auto is_datetime(std::string_view value) -> bool; + } // namespace sourcemeta::core #endif diff --git a/test/time/CMakeLists.txt b/test/time/CMakeLists.txt index a74ca3a0b..b2fb9a27b 100644 --- a/test/time/CMakeLists.txt +++ b/test/time/CMakeLists.txt @@ -1,5 +1,5 @@ sourcemeta_googletest(NAMESPACE sourcemeta PROJECT core NAME time - SOURCES gmt_test.cc) + SOURCES gmt_test.cc datetime_test.cc) target_link_libraries(sourcemeta_core_time_unit PRIVATE sourcemeta::core::time) diff --git a/test/time/datetime_test.cc b/test/time/datetime_test.cc new file mode 100644 index 000000000..7c247dfa1 --- /dev/null +++ b/test/time/datetime_test.cc @@ -0,0 +1,208 @@ +#include + +#include + +// VALID - RFC 3339 §5.6 compliant inputs + +// RFC 3339 §5.8 example: fractional seconds with UTC +TEST(Time_datetime, valid_rfc_example_1) { + EXPECT_TRUE(sourcemeta::core::is_datetime("1985-04-12T23:20:50.52Z")); +} + +// RFC 3339 §5.8 example: negative numeric offset +TEST(Time_datetime, valid_rfc_example_negative) { + EXPECT_TRUE(sourcemeta::core::is_datetime("1996-12-19T16:39:57-08:00")); +} + +// RFC 3339 §5.7: leap second at end of December +TEST(Time_datetime, valid_rfc_leap_second_1) { + EXPECT_TRUE(sourcemeta::core::is_datetime("1990-12-31T23:59:60Z")); +} + +// RFC 3339 §5.7: leap second with negative offset +TEST(Time_datetime, valid_rfc_leap_second_2) { + EXPECT_TRUE(sourcemeta::core::is_datetime("1990-12-31T15:59:60-08:00")); +} + +// RFC 3339 §5.8 example: historical offset +00:20 +TEST(Time_datetime, valid_rfc_historical) { + EXPECT_TRUE(sourcemeta::core::is_datetime("1937-01-01T12:00:27.87+00:20")); +} + +// Basic valid date-time with UTC +TEST(Time_datetime, valid_basic_utc) { + EXPECT_TRUE(sourcemeta::core::is_datetime("2024-01-15T14:30:00Z")); +} + +// §5.6 time-secfrac = "." 1*DIGIT — long fractional seconds +TEST(Time_datetime, valid_long_secfrac) { + EXPECT_TRUE(sourcemeta::core::is_datetime("2024-01-15T14:30:00.123456Z")); +} + +// §5.7: historical leap second (1998-12-31) +TEST(Time_datetime, valid_historical_leap_sec) { + EXPECT_TRUE(sourcemeta::core::is_datetime("1998-12-31T23:59:60Z")); +} + +// §1: year range includes 0000 +TEST(Time_datetime, valid_year_zero) { + EXPECT_TRUE(sourcemeta::core::is_datetime("0000-01-01T00:00:00Z")); +} + +// Appendix C: year 0000 is a leap year (0%400==0) +TEST(Time_datetime, valid_year_zero_leap_day) { + EXPECT_TRUE(sourcemeta::core::is_datetime("0000-02-29T00:00:00Z")); +} + +// §1: maximum year 9999 +TEST(Time_datetime, valid_max_year) { + EXPECT_TRUE(sourcemeta::core::is_datetime("9999-12-31T23:59:59Z")); +} + +// §5.6 NOTE: T and Z may be lowercase +TEST(Time_datetime, valid_lowercase_t_z) { + EXPECT_TRUE(sourcemeta::core::is_datetime("2024-01-15t14:30:00z")); +} + +// §5.6 time-numoffset: UTC expressed as +00:00 +TEST(Time_datetime, valid_utc_offset_zero) { + EXPECT_TRUE(sourcemeta::core::is_datetime("2024-01-15T00:00:00+00:00")); +} + +// §4.3: unknown local offset convention -00:00 +TEST(Time_datetime, valid_unknown_offset) { + EXPECT_TRUE(sourcemeta::core::is_datetime("2024-01-15T00:00:00-00:00")); +} + +// Appendix C: year 2000 is a leap year (2000%400==0) +TEST(Time_datetime, valid_year_2000_leap) { + EXPECT_TRUE(sourcemeta::core::is_datetime("2000-02-29T12:00:00Z")); +} + +// Appendix C: year 2004 is a normal leap year +TEST(Time_datetime, valid_normal_leap_year) { + EXPECT_TRUE(sourcemeta::core::is_datetime("2004-02-29T12:00:00Z")); +} + +// §5.6: fractional seconds with positive numeric offset +TEST(Time_datetime, valid_secfrac_with_offset) { + EXPECT_TRUE( + sourcemeta::core::is_datetime("1963-06-19T08:30:06.283185+01:00")); +} + +// INVALID - RFC 3339 §5.6 violations + +// §5.6: full-time requires time-offset +TEST(Time_datetime, invalid_no_timezone) { + EXPECT_FALSE(sourcemeta::core::is_datetime("2024-01-15T14:30:00")); +} + +// §5.6: time-numoffset requires colon — +0530 is colonless +TEST(Time_datetime, invalid_colonless_offset) { + EXPECT_FALSE(sourcemeta::core::is_datetime("2024-01-15T14:30:00+0530")); +} + +// §5.7/Appendix C: 2023 is not a leap year +TEST(Time_datetime, invalid_non_leap_feb29) { + EXPECT_FALSE(sourcemeta::core::is_datetime("2023-02-29T14:30:00Z")); +} + +// Appendix C: 2100%100==0 but 2100%400!=0 — not a leap year +TEST(Time_datetime, invalid_century_non_leap) { + EXPECT_FALSE(sourcemeta::core::is_datetime("2100-02-29T14:30:00Z")); +} + +// Appendix C: 1900%100==0 but 1900%400!=0 — not a leap year +TEST(Time_datetime, invalid_1900_non_leap) { + EXPECT_FALSE(sourcemeta::core::is_datetime("1900-02-29T14:30:00Z")); +} + +// §5.6: date-time = full-date "T" full-time — space is not T +TEST(Time_datetime, invalid_space_separator) { + EXPECT_FALSE(sourcemeta::core::is_datetime("2024-01-15 14:30:00Z")); +} + +// §5.6: date-time = full-date "T" full-time — tab is not T +TEST(Time_datetime, invalid_tab_separator) { + EXPECT_FALSE(sourcemeta::core::is_datetime("2024-01-15\t14:30:00Z")); +} + +// §5.6: date-fullyear = 4DIGIT — five digits invalid +TEST(Time_datetime, invalid_five_digit_year) { + EXPECT_FALSE(sourcemeta::core::is_datetime("10000-01-01T00:00:00Z")); +} + +// §5.6: date-month = 2DIGIT ; 01-12 +TEST(Time_datetime, invalid_month_13) { + EXPECT_FALSE(sourcemeta::core::is_datetime("2024-13-01T14:30:00Z")); +} + +// §5.6: date-month = 2DIGIT ; 01-12 +TEST(Time_datetime, invalid_month_00) { + EXPECT_FALSE(sourcemeta::core::is_datetime("2024-00-01T14:30:00Z")); +} + +// §5.7: date-mday starts at 01 +TEST(Time_datetime, invalid_day_00) { + EXPECT_FALSE(sourcemeta::core::is_datetime("2024-01-00T14:30:00Z")); +} + +// §5.7: day 32 always invalid +TEST(Time_datetime, invalid_day_32) { + EXPECT_FALSE(sourcemeta::core::is_datetime("2024-01-32T14:30:00Z")); +} + +// §5.7: hour only allows 00-23 (not 24) +TEST(Time_datetime, invalid_hour_24) { + EXPECT_FALSE(sourcemeta::core::is_datetime("2024-01-15T24:00:00Z")); +} + +// §5.6: time-minute = 2DIGIT ; 00-59 +TEST(Time_datetime, invalid_minute_60) { + EXPECT_FALSE(sourcemeta::core::is_datetime("2024-01-15T14:60:00Z")); +} + +// §5.6: time-second = 2DIGIT ; max 60 for leap second +TEST(Time_datetime, invalid_second_61) { + EXPECT_FALSE(sourcemeta::core::is_datetime("2024-01-15T14:30:61Z")); +} + +// Must consume entire string — no trailing characters +TEST(Time_datetime, invalid_trailing_space) { + EXPECT_FALSE(sourcemeta::core::is_datetime("2024-01-15T14:30:00Z ")); +} + +// Must start with date — no leading characters +TEST(Time_datetime, invalid_leading_space) { + EXPECT_FALSE(sourcemeta::core::is_datetime(" 2024-01-15T14:30:00Z")); +} + +// Empty string is invalid +TEST(Time_datetime, invalid_empty) { + EXPECT_FALSE(sourcemeta::core::is_datetime("")); +} + +// Completely non-date string +TEST(Time_datetime, invalid_not_a_date) { + EXPECT_FALSE(sourcemeta::core::is_datetime("not-a-date")); +} + +// §5.6: offset time-hour = 00-23 +TEST(Time_datetime, invalid_offset_hour_25) { + EXPECT_FALSE(sourcemeta::core::is_datetime("2024-01-15T14:30:00+25:00")); +} + +// §5.6: offset time-minute = 00-59 +TEST(Time_datetime, invalid_offset_minute_60) { + EXPECT_FALSE(sourcemeta::core::is_datetime("2024-01-15T14:30:00+00:60")); +} + +// §5.6: time-secfrac = "." 1*DIGIT — empty fractional part +TEST(Time_datetime, invalid_empty_secfrac) { + EXPECT_FALSE(sourcemeta::core::is_datetime("2024-01-15T14:30:00.Z")); +} + +// Appendix C: 2300%100==0 but 2300%400!=0 — not a leap year +TEST(Time_datetime, invalid_feb29_year_2300) { + EXPECT_FALSE(sourcemeta::core::is_datetime("2300-02-29T00:00:00Z")); +}