Skip to content
Open
Show file tree
Hide file tree
Changes from all 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: 2 additions & 1 deletion src/core/time/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
208 changes: 208 additions & 0 deletions src/core/time/datetime.cc
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
#include <sourcemeta/core/time.h>

#include <array> // 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<unsigned int, 13> 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<unsigned int>(value[0] - '0') * 1000 +
static_cast<unsigned int>(value[1] - '0') * 100 +
static_cast<unsigned int>(value[2] - '0') * 10 +
static_cast<unsigned int>(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<unsigned int>(value[position] - '0') * 10 +
static_cast<unsigned int>(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<unsigned int>(value[position] - '0') * 10 +
static_cast<unsigned int>(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<unsigned int>(value[position] - '0') * 10 +
static_cast<unsigned int>(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<unsigned int>(value[position] - '0') * 10 +
static_cast<unsigned int>(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<unsigned int>(value[position] - '0') * 10 +
static_cast<unsigned int>(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<unsigned int>(value[position] - '0') * 10 +
static_cast<unsigned int>(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<unsigned int>(value[position] - '0') * 10 +
static_cast<unsigned int>(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
34 changes: 30 additions & 4 deletions src/core/time/include/sourcemeta/core/time.h
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@
#include <sourcemeta/core/time_export.h>
#endif

#include <chrono> // std::chrono::system_clock::time_point
#include <string> // std::string
#include <chrono> // std::chrono::system_clock::time_point
#include <string> // std::string
#include <string_view> // 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:
///
Expand Down Expand Up @@ -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 <sourcemeta/core/time.h>
///
/// #include <cassert>
///
/// 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
2 changes: 1 addition & 1 deletion test/time/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading