Skip to content

Commit cf89346

Browse files
committed
time: Add is_datetime() for RFC 3339 Internet Date/Time Format
Implements sourcemeta::core::is_datetime() in src/core/time following the same pattern as is_ipv4() and is_ipv6() in src/core/ip. - Pure string_view state machine, no heap allocations - Validates full date-time production from RFC 3339 §5.6 - Leap year per RFC 3339 Appendix C formula - Accepts leap seconds (second=60) for any date per §5.7 - Case-insensitive T/Z separators per §5.6 NOTE - Rejects colonless offsets, space separators, missing timezone - 40 unit tests covering valid and invalid inputs Relates to format-assertion support for Draft 4 and Draft 6. Signed-off-by: Tushar Verma <tusharmyself06@gmail.com>
1 parent 29240a1 commit cf89346

File tree

5 files changed

+449
-6
lines changed

5 files changed

+449
-6
lines changed

src/core/time/CMakeLists.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
sourcemeta_library(NAMESPACE sourcemeta PROJECT core NAME time SOURCES gmt.cc)
1+
sourcemeta_library(NAMESPACE sourcemeta PROJECT core NAME time
2+
SOURCES gmt.cc datetime.cc)
23

34
if(SOURCEMETA_CORE_INSTALL)
45
sourcemeta_library_install(NAMESPACE sourcemeta PROJECT core NAME time)

src/core/time/datetime.cc

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
#include <sourcemeta/core/time.h>
2+
3+
#include <array> // std::array
4+
5+
namespace sourcemeta::core {
6+
7+
static constexpr auto is_digit(const char character) -> bool {
8+
return character >= '0' && character <= '9';
9+
}
10+
11+
static constexpr auto is_leap_year(const unsigned int year) -> bool {
12+
return (year % 4 == 0) && (year % 100 != 0 || year % 400 == 0);
13+
}
14+
15+
static constexpr auto max_day_in_month(const unsigned int month,
16+
const unsigned int year)
17+
-> unsigned int {
18+
constexpr std::array<unsigned int, 13> days{0, 31, 28, 31, 30, 31, 30,
19+
31, 31, 30, 31, 30, 31};
20+
if (month == 2 && is_leap_year(year)) {
21+
return 29;
22+
}
23+
return days[month];
24+
}
25+
26+
auto is_datetime(const std::string_view value) -> bool {
27+
const auto size{value.size()};
28+
29+
// Minimum valid date-time: "YYYY-MM-DDTHH:MM:SSZ" = 20 characters
30+
if (size < 20) {
31+
return false;
32+
}
33+
34+
std::string_view::size_type position{0};
35+
36+
// --- full-date: date-fullyear "-" date-month "-" date-mday ---
37+
38+
// date-fullyear = 4DIGIT
39+
if (!is_digit(value[0]) || !is_digit(value[1]) || !is_digit(value[2]) ||
40+
!is_digit(value[3])) {
41+
return false;
42+
}
43+
const auto year{static_cast<unsigned int>(value[0] - '0') * 1000 +
44+
static_cast<unsigned int>(value[1] - '0') * 100 +
45+
static_cast<unsigned int>(value[2] - '0') * 10 +
46+
static_cast<unsigned int>(value[3] - '0')};
47+
position = 4;
48+
49+
// "-"
50+
if (value[position] != '-') {
51+
return false;
52+
}
53+
position += 1;
54+
55+
// date-month = 2DIGIT ; 01-12
56+
if (!is_digit(value[position]) || !is_digit(value[position + 1])) {
57+
return false;
58+
}
59+
const auto month{static_cast<unsigned int>(value[position] - '0') * 10 +
60+
static_cast<unsigned int>(value[position + 1] - '0')};
61+
if (month < 1 || month > 12) {
62+
return false;
63+
}
64+
position += 2;
65+
66+
// "-"
67+
if (value[position] != '-') {
68+
return false;
69+
}
70+
position += 1;
71+
72+
// date-mday = 2DIGIT ; 01-28/29/30/31 based on month/year
73+
if (!is_digit(value[position]) || !is_digit(value[position + 1])) {
74+
return false;
75+
}
76+
const auto day{static_cast<unsigned int>(value[position] - '0') * 10 +
77+
static_cast<unsigned int>(value[position + 1] - '0')};
78+
position += 2;
79+
80+
// --- "T" or "t" separator ---
81+
if (value[position] != 'T' && value[position] != 't') {
82+
return false;
83+
}
84+
position += 1;
85+
86+
// --- partial-time: time-hour ":" time-minute ":" time-second ---
87+
88+
// time-hour = 2DIGIT ; 00-23
89+
if (!is_digit(value[position]) || !is_digit(value[position + 1])) {
90+
return false;
91+
}
92+
const auto hour{static_cast<unsigned int>(value[position] - '0') * 10 +
93+
static_cast<unsigned int>(value[position + 1] - '0')};
94+
if (hour > 23) {
95+
return false;
96+
}
97+
position += 2;
98+
99+
// ":"
100+
if (value[position] != ':') {
101+
return false;
102+
}
103+
position += 1;
104+
105+
// time-minute = 2DIGIT ; 00-59
106+
if (!is_digit(value[position]) || !is_digit(value[position + 1])) {
107+
return false;
108+
}
109+
const auto minute{static_cast<unsigned int>(value[position] - '0') * 10 +
110+
static_cast<unsigned int>(value[position + 1] - '0')};
111+
if (minute > 59) {
112+
return false;
113+
}
114+
position += 2;
115+
116+
// ":"
117+
if (value[position] != ':') {
118+
return false;
119+
}
120+
position += 1;
121+
122+
// time-second = 2DIGIT ; 00-60 (60 = leap second per §5.7)
123+
if (!is_digit(value[position]) || !is_digit(value[position + 1])) {
124+
return false;
125+
}
126+
const auto second{static_cast<unsigned int>(value[position] - '0') * 10 +
127+
static_cast<unsigned int>(value[position + 1] - '0')};
128+
if (second > 60) {
129+
return false;
130+
}
131+
position += 2;
132+
133+
// --- [time-secfrac] = "." 1*DIGIT ---
134+
if (position < size && value[position] == '.') {
135+
position += 1;
136+
if (position >= size || !is_digit(value[position])) {
137+
// "." must be followed by at least 1 digit
138+
return false;
139+
}
140+
while (position < size && is_digit(value[position])) {
141+
position += 1;
142+
}
143+
}
144+
145+
// --- time-offset = "Z" / time-numoffset ---
146+
if (position >= size) {
147+
// No time offset present — invalid
148+
return false;
149+
}
150+
151+
if (value[position] == 'Z' || value[position] == 'z') {
152+
position += 1;
153+
} else if (value[position] == '+' || value[position] == '-') {
154+
position += 1;
155+
156+
// time-numoffset = ("+" / "-") time-hour ":" time-minute
157+
if (position + 5 > size) {
158+
return false;
159+
}
160+
161+
// Offset time-hour = 2DIGIT ; 00-23
162+
if (!is_digit(value[position]) || !is_digit(value[position + 1])) {
163+
return false;
164+
}
165+
const auto offset_hour{
166+
static_cast<unsigned int>(value[position] - '0') * 10 +
167+
static_cast<unsigned int>(value[position + 1] - '0')};
168+
if (offset_hour > 23) {
169+
return false;
170+
}
171+
position += 2;
172+
173+
// ":" — REQUIRED (colonless offsets like +0530 are invalid per §5.6)
174+
if (value[position] != ':') {
175+
return false;
176+
}
177+
position += 1;
178+
179+
// Offset time-minute = 2DIGIT ; 00-59
180+
if (!is_digit(value[position]) || !is_digit(value[position + 1])) {
181+
return false;
182+
}
183+
const auto offset_minute{
184+
static_cast<unsigned int>(value[position] - '0') * 10 +
185+
static_cast<unsigned int>(value[position + 1] - '0')};
186+
if (offset_minute > 59) {
187+
return false;
188+
}
189+
position += 2;
190+
} else {
191+
// Not Z, not +/-, invalid character for time-offset
192+
return false;
193+
}
194+
195+
// String must be fully consumed — no trailing characters
196+
if (position != size) {
197+
return false;
198+
}
199+
200+
// --- Validate date-mday against month/year (§5.7) ---
201+
if (day < 1 || day > max_day_in_month(month, year)) {
202+
return false;
203+
}
204+
205+
return true;
206+
}
207+
208+
} // namespace sourcemeta::core

src/core/time/include/sourcemeta/core/time.h

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@
55
#include <sourcemeta/core/time_export.h>
66
#endif
77

8-
#include <chrono> // std::chrono::system_clock::time_point
9-
#include <string> // std::string
8+
#include <chrono> // std::chrono::system_clock::time_point
9+
#include <string> // std::string
10+
#include <string_view> // std::string_view
1011

1112
/// @defgroup time Time
12-
/// @brief A growing implementation of time-related utilities for standard such
13-
/// as RFC 7231 (GMT).
13+
/// @brief A growing implementation of time-related utilities for standards
14+
/// such as RFC 7231 (GMT) and RFC 3339 (Internet Date/Time Format).
1415
///
1516
/// This functionality is included as follows:
1617
///
@@ -79,6 +80,31 @@ auto to_gmt(const std::chrono::system_clock::time_point time) -> std::string;
7980
SOURCEMETA_CORE_TIME_EXPORT
8081
auto from_gmt(const std::string &time) -> std::chrono::system_clock::time_point;
8182

83+
/// @ingroup time
84+
/// Check whether the given string is a valid date-time value per RFC 3339
85+
/// Section 5.6 (Internet Date/Time Format). This implements the full
86+
/// `date-time` production rule:
87+
///
88+
/// ```
89+
/// date-time = full-date "T" full-time
90+
/// ```
91+
///
92+
/// where "T" may also be lowercase "t" (per RFC 3339 §5.6 NOTE). For example:
93+
///
94+
/// ```cpp
95+
/// #include <sourcemeta/core/time.h>
96+
///
97+
/// #include <cassert>
98+
///
99+
/// assert(sourcemeta::core::is_datetime("1985-04-12T23:20:50.52Z"));
100+
/// assert(sourcemeta::core::is_datetime("1996-12-19T16:39:57-08:00"));
101+
/// assert(sourcemeta::core::is_datetime("1990-12-31T23:59:60Z"));
102+
/// assert(!sourcemeta::core::is_datetime("2024-01-15T14:30:00"));
103+
/// assert(!sourcemeta::core::is_datetime("2024-01-15 14:30:00Z"));
104+
/// ```
105+
SOURCEMETA_CORE_TIME_EXPORT
106+
auto is_datetime(std::string_view value) -> bool;
107+
82108
} // namespace sourcemeta::core
83109

84110
#endif

test/time/CMakeLists.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
sourcemeta_googletest(NAMESPACE sourcemeta PROJECT core NAME time
2-
SOURCES gmt_test.cc)
2+
SOURCES gmt_test.cc datetime_test.cc)
33

44
target_link_libraries(sourcemeta_core_time_unit
55
PRIVATE sourcemeta::core::time)

0 commit comments

Comments
 (0)