Skip to content

Commit f99c46c

Browse files
committed
feat(core): widen the date/time parse grammar to .NET's (#1929 rows 1 and 3)
Decided by the user on 2026-08-18: widen all four of #1929's recorded respects. Row 2 (a fractional second at 100-ns precision) already landed with rows 5-6 on 2026-08-01. Row 4 is the DateTimeKind and ParseExact surface and stays open as #1941 and #1939. This is rows 1 and 3. Row 1 the MONTH and DAY admit one or two digits, so "2024-6-15" parses. Row 3 the OFFSET is .NET's ParseTimeZone grammar, so "+8", "+2:5", "+800" and "+0800" join "+02:05". It is a PURE WIDENING. No string that parsed before parses differently or fails now, so there is no source break, no signature change, no layout change and nothing for a caller to migrate -- only a note that text .NET accepts is now accepted here too. Landed under SA-5. Both halves are transcribed, not guessed. * .NET's lexer types a digit run of one or two as a NumberToken and three or more as a YearNumberToken (Globalization/DateTimeParse.cs:5593-5605), so "2024-6-15" produces exactly the token sequence "2024-06-15" produces and reaches the same year-month-day terminal state. * ParseTimeZone (DateTimeParse.cs:530-556) reads ONE digit run after the sign: length 1-2 is an hour, optionally followed by ':' and a 1-2 digit minute; length 3-4 is split `value / 100` and `value % 100`. That integer split is WHY "+800" and "+0800" agree, and why "+205" means 125 minutes. A minute field of 60 or more is rejected, which is .NET's only check there (DateTimeParse.cs:565-568). Two stops are deliberate and both are pinned. * The YEAR is not widened. .NET would read a short one through Calendar.ToFourDigitYear, whose century window is culture state this port has no way to carry, so "24-06-15" stays a failure rather than silently landing in a century nobody chose. * The +/-14h bound stays on DateTimeOffset and does NOT move into the shared grammar, because that is where .NET puts it: ParseTimeZone permits an hour up to 99 and the MinOffset/MaxOffset test runs later, at the two sites that actually STORE an offset (DateTimeParse.cs:2777,2875). System::DateTime parses an offset and discards it -- it has no DateTimeKind -- so it must not inherit a bound on a value it never keeps. DateTime::TryParse("...+99") succeeds and DateTimeOffset::TryParse("...+99") fails, and one test pins both halves of that sentence. THE WIDENING FORCED A STRUCTURAL REPAIR THE TICKET DID NOT NAME. DateTimeOffset::TryParse copied its input into a std::string, searched for a '+' or '-' starting at CHARACTER 10, and handed the prefix to DateTime::TryParse. That split is correct only while the date part is exactly ten characters wide, and "2024-6-5" is eight -- so the search was about to start inside the date, or, for a bare short date, past the end, which the `input.size() < 10` precheck was quietly standing in for. The three doors now share one grammar in System/detail/DateTimeTextScanner.hpp. They had drifted apart precisely because they did not. Ten existing pins are inverted in place, six cases added: SharpRuntimeTests_Core_Base 6,019 -> 6,025. Two of the inverted ones are worth naming. Ccf002_UnpaddedOffsetRejectionIsANarrowingNotAParityFix recorded that #1879's rejection of "+2:5" was a NARROWING and not a parity fix, since .NET reads it as 125 minutes -- the value this port already produced. It now pins the agreement itself, which is what it was always about. Nine mutations, all caught: month/day back to two digits (shared grammar, and DateOnly's own copy, separately); dropping the minute<60 check; swapping the hour/minute split of a 3-4 digit run; treating a 3-digit run as an hour; ignoring the minus sign; moving the +/-14h bound into the shared grammar; restoring the `size() < 10` precheck; letting the offset run reach five digits; widening the year to 1-4 digits. Downstream, measured per SA-2 condition 5: neither cna nor mobile-eggbert calls DateTime/DateTimeOffset/DateOnly Parse or TryParse at all -- zero sites in both. Neither repository was modified. A widening could not have changed their answers in any case. Gate: 17,296 run, 17,296 passed, 0 failed, 0 skipped across 38 executables, GREEN, confirmed by two consecutive full runs. docs/Migration-DateTimeGrammarWidening.md
1 parent fb69aa8 commit f99c46c

10 files changed

Lines changed: 511 additions & 148 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
<!-- SPDX-License-Identifier: MIT -->
2+
<!-- Copyright (c) Robert Vokac and contributors -->
3+
4+
# Migration — the date/time parse grammar widens to .NET's (ticket #1929)
5+
6+
*2026-08-18.* `System::DateTime`, `System::DateTimeOffset` and `System::DateOnly` now accept a
7+
**one- or two-digit month and day**, and all three accept .NET's full **time-zone offset**
8+
grammar rather than a fixed `±HH:MM`.
9+
10+
**This is a pure widening.** No string that parsed before parses differently or fails now, so
11+
there is no source break, no signature change, no layout change, and nothing to migrate. This
12+
note exists because text .NET accepts is now accepted here too, and a reader should know which
13+
text that is.
14+
15+
Decided by the user on 2026-08-18, on ticket #1929's four recorded respects. Landed under
16+
`docs/StandingApprovals.md` SA-5 (derived from the reference, never guessed).
17+
18+
---
19+
20+
## 1. What is newly accepted
21+
22+
| Input | Was | Is |
23+
|---|---|---|
24+
| `"2024-6-15"` | rejected | 15 June 2024 |
25+
| `"2024-06-5"` | rejected | 5 June 2024 |
26+
| `"2024-6-5T1:2:3-05:00"` | rejected | that instant at −05:00 |
27+
| `"…+8"` / `"…-8"` | rejected | ±8 h |
28+
| `"…+08"` | rejected | +8 h |
29+
| `"…+2:5"` | rejected | **125 min** — 2 h 05 m |
30+
| `"…+02:5"`, `"…+2:05"` | rejected | 125 min |
31+
| `"…+800"`, `"…+0800"` | rejected | +8 h |
32+
| `"…+205"`, `"…+0205"` | rejected | 125 min |
33+
| `"…-0530"` | rejected | −330 min |
34+
| everything that parsed before || **the identical value** |
35+
36+
## 2. What is still rejected, and why
37+
38+
| Input | Why |
39+
|---|---|
40+
| `"24-06-15"`, `"204-06-15"` | the year is exactly four digits — see §4 |
41+
| `"2024-006-15"`, `"2024-06-015"` | a field is at most two digits |
42+
| `"2024 -06-15"`, `"2024-06- 15"` | internal whitespace is grammar, and only the **outer** boundary is trimmed (#1929 row 5) |
43+
| `"…+2:60"`, `"…+0860"` | a minute field of 60 or more — .NET's own check, `DateTimeParse.cs:565-568` |
44+
| `"…+12345"` | a run of five digits is not an offset |
45+
| `"…+"`, `"…+2:"` | a sign or a colon with nothing after it |
46+
| `"…+02:00junk"` | the string must be consumed in full (#1879) |
47+
| `"June 15 2024"` | month names and culture patterns remain out of scope |
48+
49+
## 3. Where each rule comes from
50+
51+
Both halves are transcribed, not invented.
52+
53+
**The date.** .NET's lexer classifies a run of one or two digits as a `NumberToken` and a run of
54+
three or more as a `YearNumberToken` (`Globalization/DateTimeParse.cs:5593-5605`). `"2024-6-15"`
55+
therefore produces exactly the token sequence `"2024-06-15"` produces — year, number, number — and
56+
reaches the same year-month-day terminal state. .NET accepts it; this port now does too.
57+
58+
**The offset.** `ParseTimeZone` (`DateTimeParse.cs:530-556`) reads one digit run after the sign:
59+
60+
* length **1 or 2** — that is the hour, and a `':'` may follow with a **1- or 2-digit** minute;
61+
* length **3 or 4** — the hour is `value / 100` and the minute is `value % 100`;
62+
* anything else fails.
63+
64+
That integer split is why `"+800"` and `"+0800"` agree, and why `"+205"` means 125 minutes.
65+
66+
## 4. Two deliberate stops
67+
68+
**The year is not widened.** .NET would read a one- or two-digit year through
69+
`Calendar.ToFourDigitYear`, whose century window is culture state (`TwoDigitYearMax`) that this
70+
port has no way to carry. A short year stays a failure rather than silently landing in a century
71+
nobody chose.
72+
73+
**The ±14 h bound stays on `DateTimeOffset` and does not move into the shared grammar.** That is
74+
where .NET puts it: `ParseTimeZone` permits an hour up to 99, and the
75+
`DateTimeOffset.MinOffset`/`MaxOffset` test runs later, at the two sites that actually store an
76+
offset (`DateTimeParse.cs:2777,2875`). `System::DateTime` parses an offset and **discards** it —
77+
it has no `DateTimeKind` (`docs/DateTimeValidationBoundaryPlan.md` §16.4) — so it must not inherit
78+
a bound on a value it never keeps. `DateTime::TryParse("…+99")` succeeds and
79+
`DateTimeOffset::TryParse("…+99")` fails, and a test pins both halves of that sentence.
80+
81+
## 5. One structural change worth knowing about
82+
83+
`DateTimeOffset::TryParse` used to copy its input into a `std::string`, search for a `'+'` or
84+
`'-'` **starting at character 10**, and hand the prefix to `DateTime::TryParse`. That split is
85+
correct only while the date part is exactly ten characters wide. Widening the month and day makes
86+
`"2024-6-5"` eight characters, so the search was about to start inside the date — or, for a bare
87+
short date, past the end, which the `input.size() < 10` precheck was quietly standing in for.
88+
89+
The three doors now share one grammar in
90+
`modules/core/include/System/detail/DateTimeTextScanner.hpp`. They had drifted apart precisely
91+
because they did not.
92+
93+
## 6. Downstream, measured
94+
95+
Neither `cna` nor `mobile-eggbert` calls `DateTime::Parse`, `DateTime::TryParse`,
96+
`DateTimeOffset::Parse`/`TryParse` or `DateOnly::Parse`/`TryParse` at all — **zero sites in
97+
both**. Neither repository was modified. Since the change is a widening, even a caller that did
98+
parse dates would see no existing input change its value.

modules/core/include/System/detail/DateTimeTextScanner.hpp

Lines changed: 128 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,11 @@
2626
* outside `int` by never reading more digits than fit.
2727
*
2828
* Deliberately **not** a general lexer: the port implements a narrow ISO-8601
29-
* subset by prior decision (`DateTime.hpp`, `TimeOnly.hpp`), and widening it is
30-
* new API surface, not a validation repair
31-
* (`docs/DateTimeValidationBoundaryPlan.md` §16.4).
29+
* subset by prior decision (`DateTime.hpp`, `TimeOnly.hpp`). Ticket **#1929**
30+
* (decided 2026-08-18) widened four respects of that subset to .NET's grammar --
31+
* see `takeDateTimeParts` and `takeUtcOffsetMinutes` below -- but the subset is
32+
* still a subset: no month names, no culture patterns, no two-digit year, and no
33+
* `ParseExact` (`docs/DateTimeValidationBoundaryPlan.md` §16.4).
3234
*/
3335
namespace System::detail {
3436

@@ -74,7 +76,7 @@ class DateTimeTextScanner {
7476
* whole point of not using `std::sscanf`. Consumes nothing and returns
7577
* @c false unless at least @p minDigits digits are available; stops after
7678
* @p maxDigits so the accumulated value cannot overflow @c int (callers pass
77-
* at most 4).
79+
* at most 7).
7880
*
7981
* @param minDigits Fewest digits that make the field well-formed.
8082
* @param maxDigits Most digits the field may hold (<= 9).
@@ -106,4 +108,126 @@ class DateTimeTextScanner {
106108
std::size_t index_ = 0;
107109
};
108110

111+
/**
112+
* @brief The calendar and clock fields one date/time string carries.
113+
*
114+
* `hasTime` distinguishes a bare date from one whose clock fields are all zero,
115+
* which the parsers do not need but a caller reading this struct might.
116+
*/
117+
struct DateTimeParts {
118+
int year = 0;
119+
int month = 0;
120+
int day = 0;
121+
int hour = 0;
122+
int minute = 0;
123+
int second = 0;
124+
int fractionTicks = 0;
125+
bool hasTime = false;
126+
};
127+
128+
/**
129+
* @brief Consumes this port's ISO-8601 date-and-optional-time prefix.
130+
*
131+
* @verbatim
132+
* yyyy '-' M{1,2} '-' d{1,2}
133+
* [ (' '|'T') H{1,2} ':' m{1,2} ':' s{1,2} [ '.' f{1,7} ] ]
134+
* @endverbatim
135+
*
136+
* Ticket #1929 rows 1-2 (decided 2026-08-18) widened the **month and day** to one
137+
* or two digits, matching .NET: its lexer classifies a run of one or two digits as
138+
* a `NumberToken` and three or more as a `YearNumberToken`
139+
* (`Globalization/DateTimeParse.cs:5593-5605`), so `"2024-6-15"` reaches the same
140+
* year-month-day terminal state that `"2024-06-15"` does. The **year** stays
141+
* exactly four digits: that is this port's ISO subset, and a two-digit year would
142+
* pull in `Calendar.ToFourDigitYear`'s culture-dependent century window.
143+
*
144+
* The fraction is read at 100-ns precision because `DateTime` is tick-based;
145+
* `.1234567` keeps all seven digits, and an eighth is rejected rather than read as
146+
* a prefix.
147+
*
148+
* Defined once here because three doors share the grammar. When they held their
149+
* own copies they drifted: `DateTimeOffset` located its offset by scanning for a
150+
* sign from **character 10**, which a one-digit month or day makes wrong.
151+
*
152+
* @param scanner Cursor to read from; consumes nothing on failure only insofar as
153+
* each field is individually atomic — a caller that needs to retry
154+
* should construct a fresh scanner.
155+
* @param parts Receives the fields that were present.
156+
* @return @c true when the whole prefix is well-formed.
157+
*/
158+
[[nodiscard]] inline bool takeDateTimeParts(DateTimeTextScanner& scanner,
159+
DateTimeParts& parts) noexcept {
160+
if (!scanner.takeDigits(4, 4, parts.year) || !scanner.take('-') ||
161+
!scanner.takeDigits(1, 2, parts.month) || !scanner.take('-') ||
162+
!scanner.takeDigits(1, 2, parts.day))
163+
return false;
164+
165+
if (scanner.take(' ') || scanner.take('T')) {
166+
if (!scanner.takeDigits(1, 2, parts.hour) || !scanner.take(':') ||
167+
!scanner.takeDigits(1, 2, parts.minute) || !scanner.take(':') ||
168+
!scanner.takeDigits(1, 2, parts.second))
169+
return false;
170+
parts.hasTime = true;
171+
if (scanner.take('.')) {
172+
int digits = 0;
173+
if (!scanner.takeDigits(1, 7, parts.fractionTicks, &digits)) return false;
174+
while (digits < 7) { parts.fractionTicks *= 10; ++digits; }
175+
}
176+
}
177+
return true;
178+
}
179+
180+
/**
181+
* @brief Consumes a signed UTC offset, transcribing .NET's `ParseTimeZone`.
182+
*
183+
* @verbatim
184+
* ('+'|'-') ( d{1,2} [ ':' d{1,2} ] | d{3,4} )
185+
* @endverbatim
186+
*
187+
* The three-or-four-digit run is split as `value / 100` hours and `value % 100`
188+
* minutes, so `"+800"` and `"+0800"` both mean eight hours
189+
* (`Globalization/DateTimeParse.cs:530-556`). The minute field is rejected at 60
190+
* and above, which is .NET's only check here
191+
* (`DateTimeParse.cs:565-568`); the +/-14h bound belongs to the caller, because
192+
* .NET applies it later and only where an offset is actually stored
193+
* (`DateTimeParse.cs:2777,2875`).
194+
*
195+
* Ticket #1929 row 3 (decided 2026-08-18). Before it, all three doors demanded
196+
* exactly `+HH:MM`, so `"+2:5"`, `"+8"` and `"+0800"` were rejected -- and the
197+
* first of those had been read as 2h05m, the value .NET gives it, until #1879
198+
* narrowed the grammar.
199+
*
200+
* @param scanner Cursor positioned at the sign character.
201+
* @param signedMinutes Receives the offset in minutes, negative for `'-'`.
202+
* @return @c true when a well-formed offset was consumed.
203+
*/
204+
[[nodiscard]] inline bool takeUtcOffsetMinutes(DateTimeTextScanner& scanner,
205+
int& signedMinutes) noexcept {
206+
bool negative = false;
207+
if (scanner.take('-')) {
208+
negative = true;
209+
} else if (!scanner.take('+')) {
210+
return false;
211+
}
212+
213+
int value = 0, digits = 0;
214+
if (!scanner.takeDigits(1, 4, value, &digits)) return false;
215+
216+
int hours = 0, minutes = 0;
217+
if (digits <= 2) {
218+
hours = value;
219+
if (scanner.take(':')) {
220+
if (!scanner.takeDigits(1, 2, minutes)) return false;
221+
}
222+
} else {
223+
hours = value / 100;
224+
minutes = value % 100;
225+
}
226+
if (minutes >= 60) return false;
227+
228+
signedMinutes = hours * 60 + minutes;
229+
if (negative) signedMinutes = -signedMinutes;
230+
return true;
231+
}
232+
109233
} // namespace System::detail

modules/core/src/System/DateOnly.cpp

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -178,12 +178,17 @@ bool DateOnly::TryParse(const std::string& s, DateOnly& result) {
178178
// "2024-06-15 10:20:30" -- a full timestamp silently truncated to its date.
179179
// The grammar is now required to match the WHOLE string:
180180
//
181-
// yyyy '-' MM '-' dd [ 'Z'|'z' ]
181+
// yyyy '-' M{1,2} '-' d{1,2} [ 'Z'|'z' ]
182+
//
183+
// #1929 row 1 (decided 2026-08-18): the month and day admit one or two digits,
184+
// as .NET's do. The year stays exactly four -- three or fewer would collide with
185+
// .NET's two-digit-year century window, which is culture state this port has no
186+
// way to carry.
182187
detail::DateTimeTextScanner scanner(detail::trimDateTimeText(s));
183188
int y = 0, m = 0, d = 0;
184189
if (!scanner.takeDigits(4, 4, y) || !scanner.take('-') ||
185-
!scanner.takeDigits(2, 2, m) || !scanner.take('-') ||
186-
!scanner.takeDigits(2, 2, d))
190+
!scanner.takeDigits(1, 2, m) || !scanner.take('-') ||
191+
!scanner.takeDigits(1, 2, d))
187192
return fail();
188193
// A trailing UTC designator stays accepted and stays ignored, matching
189194
// DateTime::TryParse; "2024-06-15Z" parsed before this ticket and still does.

modules/core/src/System/DateTime.cpp

Lines changed: 25 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -410,58 +410,50 @@ namespace System {
410410
// (docs/DateTimeValidationBoundaryPlan.md §16.4 keeps widening it out of
411411
// scope), now required to match the WHOLE string:
412412
//
413-
// W* yyyy '-' MM '-' dd
413+
// W* yyyy '-' M{1,2} '-' d{1,2}
414414
// [ (' '|'T') H{1,2} ':' m{1,2} ':' s{1,2} [ '.' f{1,7} ] ]
415-
// [ 'Z'|'z' ] W*
415+
// [ 'Z'|'z' | ('+'|'-') offset ] W*
416416
//
417417
// Every currently-valid input still produces its exact previous value.
418418
// Correction/remediation (#1929 rows 5-6, approved 2026-08-01): the preceding
419419
// historical grammar accurately described the repaired #1879 subset, but it was
420420
// narrower than both the related port doors and current .NET. Outer invariant
421421
// whitespace, one-or-two digit clock fields, and one through seven fractional
422-
// digits are now the approved shared contract. Date fields, offsets and internal
423-
// whitespace remain deliberately fixed/rejected.
422+
// digits became the approved shared contract then; internal whitespace is still
423+
// grammar, and still rejected.
424+
//
425+
// #1929 rows 1 and 3 (decided 2026-08-18) finish the job: the MONTH and DAY
426+
// admit one or two digits, and the offset is .NET's ParseTimeZone grammar. Both
427+
// are pure widenings -- no string that parsed yesterday parses differently or
428+
// fails today -- and both now live in System/detail/DateTimeTextScanner.hpp so
429+
// that the three doors sharing them cannot drift apart again.
424430
detail::DateTimeTextScanner scanner(detail::trimDateTimeText(s));
425-
int yr = 0, mo = 0, dy = 0, hr = 0, mn = 0, sc = 0, fractionTicks = 0;
426-
if (!scanner.takeDigits(4, 4, yr) || !scanner.take('-') ||
427-
!scanner.takeDigits(2, 2, mo) || !scanner.take('-') ||
428-
!scanner.takeDigits(2, 2, dy))
429-
return fail();
430-
431-
if (scanner.take(' ') || scanner.take('T')) {
432-
if (!scanner.takeDigits(1, 2, hr) || !scanner.take(':') ||
433-
!scanner.takeDigits(1, 2, mn) || !scanner.take(':') ||
434-
!scanner.takeDigits(1, 2, sc))
435-
return fail();
436-
if (scanner.take('.')) {
437-
// A fraction must have 1-7 digits. A bare ".", a non-numeric
438-
// ".abc", and an eighth digit are rejected rather than read as a
439-
// prefix. DateTime is tick-based, so every accepted digit is retained.
440-
int digits = 0;
441-
if (!scanner.takeDigits(1, 7, fractionTicks, &digits)) return fail();
442-
while (digits < 7) { fractionTicks *= 10; ++digits; }
443-
}
444-
}
431+
detail::DateTimeParts parts;
432+
if (!detail::takeDateTimeParts(scanner, parts)) return fail();
433+
445434
// A trailing time-zone designator stays accepted, and stays ignored: this
446435
// port has no DateTimeKind (§16.4), so "…T10:20:30Z" and
447436
// "…T10:20:30.123+02:00" keep the exact values they have always had.
448437
// §20.1's "unchanged in every option" list names both spellings, and
449-
// three DateTimeTests pin the offset one. The offset fields are two
450-
// digits each, the same rule DateTimeOffset applies -- and the same rule
451-
// the date fields have always had, since "2024-6-15" was never accepted
452-
// either.
438+
// three DateTimeTests pin the offset one.
439+
//
440+
// #1929 row 3 (decided 2026-08-18) widens WHICH offsets are accepted, not
441+
// what any of them mean here: "+8", "+2:5", "+800" and "+0800" join
442+
// "+02:05". The grammar lives in one place now, shared with
443+
// DateTimeOffset, which is the door that actually uses the value.
453444
if (!scanner.take('Z') && !scanner.take('z')) {
454-
if (scanner.take('+') || scanner.take('-')) {
455-
int offsetHours = 0, offsetMinutes = 0;
456-
if (!scanner.takeDigits(2, 2, offsetHours) || !scanner.take(':') ||
457-
!scanner.takeDigits(2, 2, offsetMinutes))
445+
if (!scanner.atEnd()) {
446+
int ignoredOffsetMinutes = 0;
447+
if (!detail::takeUtcOffsetMinutes(scanner, ignoredOffsetMinutes))
458448
return fail();
459449
}
460450
}
461451
if (!scanner.atEnd()) return fail();
462452

463453
try {
464-
result = DateTime(dateToTicks(yr, mo, dy, hr, mn, sc) + fractionTicks);
454+
result = DateTime(dateToTicks(parts.year, parts.month, parts.day,
455+
parts.hour, parts.minute, parts.second) +
456+
parts.fractionTicks);
465457
return true;
466458
} catch (...) { return fail(); }
467459
}

0 commit comments

Comments
 (0)