Skip to content

Commit b45a153

Browse files
committed
feat(core): multi-format ParseExact across all five exact-parsing types (#1944)
The last open post-audit implementation ticket. One ordered first-success loop shared by DateTime, DateTimeOffset, DateOnly, TimeOnly and TimeSpan -- five copies of one taxonomy is how five doors come to disagree, and .NET writes it twice with the two agreeing. Ordering is pinned with an input two formats both accept: 01/02/2024 is 2 January under MM/dd and 1 February under dd/MM, so the two orders give different DATES rather than a different code path. An empty ELEMENT aborts the whole loop rather than being skipped, and the pin puts it before a format that would have matched, so the plausible-wrong rule succeeds where the right one fails. Two failure kinds, because .NET gives them two messages -- found by measurement: with the empty-collection guard removed the loop body never runs and the fall-through gives the same answer, so it would have been a proven equivalence. Carrying both messages makes it load-bearing and gives the right diagnosis. The overload hazard the acceptance criteria anticipated was real: {"a", "b"} and {"one"} were ambiguous, because two const char* in braces match basic_string(InputIt, InputIt) over two UNRELATED pointers -- and had that candidate won, the result would be undefined behaviour rather than a wrong answer, which is what decided a fix over documenting a papercut. initializer_list overloads resolve it, and a case asserts the unbraced single-format overload is still reachable. Four mutations, three caught; M3 is a proven equivalence recorded at the site, and .NET's is an equivalence too. The span-like shapes are recorded rather than taken: a second text representation beside const std::string& repeats this very hazard, where the wrong branch is silent rather than ambiguous. Gate 17,732/38 green. Zero downstream sites.
1 parent 4594cbe commit b45a153

17 files changed

Lines changed: 909 additions & 1 deletion

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

NEXT.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,32 @@
139139
> and each is itemised in §2 below. **§4b says where to look next, and the method that found all
140140
> three of today's tickets.**
141141

142+
## 2026-08-20 — #1944: multi-format `ParseExact` — **the last open post-audit ticket**
143+
144+
**Gate: 17,732 / 38, 0 failed, 0 skipped** (+7, `Core_Base` 6,141 → 6,148).
145+
146+
All five exact-parsing types gain multi-format `ParseExact`, sharing **one** ordered first-success
147+
loop. **Ordering is pinned with an input two formats both accept**: `01/02/2024` is 2 January under
148+
`MM/dd` and 1 February under `dd/MM`, so the two orders give **different dates**.
149+
150+
**An empty element aborts the whole loop rather than being skipped** — the pin puts it *before* a
151+
format that would have matched, so the plausible-wrong rule succeeds where the right one fails.
152+
153+
**Two failure kinds, because .NET gives them two messages**, and that was found by measurement: with
154+
the empty-collection guard removed the loop never runs and the fall-through gives the same answer,
155+
so it **would have been a proven equivalence**.
156+
157+
**The overload hazard the acceptance criteria anticipated was real.** `ParseExact(s, {"a", "b"})`
158+
and `{"one"}` were **ambiguous**, because two `const char*` in braces match
159+
`basic_string(InputIt, InputIt)` over two **unrelated** pointers — **and had that candidate won, the
160+
result would be undefined behaviour**, which is what decided a fix over documenting a papercut.
161+
`std::initializer_list` overloads resolve it.
162+
163+
**The span-like shapes are recorded rather than taken**: a second text representation beside
164+
`const std::string&` repeats this very hazard, in a place where the wrong branch is *silent*.
165+
166+
`docs/Migration-MultiFormatParseExact.md`. Downstream zero sites.
167+
142168
## 2026-08-20 — #2416: `DateTime::ToString` had no standard-format table at all
143169

144170
**Gate: 17,725 / 38, 0 failed, 0 skipped** (+5, `Core_Base` 6,136 → 6,141; **no other executable
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
<!-- SPDX-License-Identifier: MIT -->
2+
<!-- Copyright (c) Robert Vokac and contributors -->
3+
4+
# Multi-format `ParseExact` across all five exact-parsing types — #1944
5+
6+
**Purely additive**, under SA-5. No existing signature, layout, vtable, `noexcept` specification or
7+
accepted input changed. This is the **last open post-audit implementation ticket**.
8+
9+
## One loop for five types
10+
11+
`DateTime`, `DateTimeOffset`, `DateOnly`, `TimeOnly` and `TimeSpan` all gain
12+
`ParseExact(input, formats, …)` / `TryParseExact(…)`. The ordered first-success loop is
13+
`detail::MatchFirstOfManyFormats`, written **once** — five copies of one taxonomy is how five doors
14+
come to disagree about what an empty element means. .NET writes it twice
15+
(`TryParseExactMultiple`, `TryParseExactMultipleTimeSpan`) and the two agree, so sharing is faithful
16+
rather than a shortcut.
17+
18+
## The rules, and the one a plausible implementation gets wrong
19+
20+
* **Ordered, first success wins.** Pinned with an input two formats both accept:
21+
`01/02/2024` is 2 January under `MM/dd` and 1 February under `dd/MM`, so the two orders give
22+
**different dates** rather than merely a different code path.
23+
* **An empty element ABORTS the whole loop rather than being skipped.** .NET returns
24+
`SetBadFormatSpecifierFailure` immediately. *"Skip it and carry on"* is the plausible
25+
implementation and it is wrong; the pin puts the empty element **before** a format that would
26+
have matched, so the wrong rule succeeds where the right one fails.
27+
* **An empty collection is a FORMAT failure, not an argument one** — .NET's
28+
`Format_NoFormatSpecifier`, easy to get wrong in the direction of `ArgumentException`.
29+
* **.NET's null-array arm has no C++ counterpart** and is deliberately not reproduced: the
30+
parameter is a `const std::vector<std::string>&`, which cannot be null.
31+
* **The style is validated once, before the loop**, so an illegal style raises whatever the formats
32+
are — including an empty collection, where no single-format call would ever run. Validating
33+
inside the loop would make the exception depend on the format list.
34+
35+
## Two failure kinds, because .NET gives them two messages
36+
37+
Measured while writing the tests: with the empty-collection guard simply **removed**, the loop body
38+
never runs and the fall-through gives the same answer — so the guard would be a **proven
39+
equivalence** and the mutation uncaught. But **.NET's two failures carry different messages**:
40+
*"No format specifiers were provided."* against *"String was not recognized as a valid …"*.
41+
42+
Carrying both makes the guard load-bearing **and** gives the right diagnosis: telling a caller who
43+
supplied no formats that their *input* was unrecognised is wrong. An **empty element** gets the
44+
format-specifier message too, being the same kind — the caller's formats are wrong, not their input.
45+
46+
## The overload-resolution hazard the ticket anticipated
47+
48+
#1944's acceptance criteria asked for *"compile ambiguity fixtures"*, and there was a real one.
49+
Measured with a five-way probe:
50+
51+
| spelling | before |
52+
|---|---|
53+
| `ParseExact(s, {"a", "b"})` | **ambiguous** |
54+
| `ParseExact(s, {"one"})` | **ambiguous** |
55+
| `ParseExact(s, {"a", "b", "c"})` | ok |
56+
| `ParseExact(s, std::vector<std::string>{…})` | ok |
57+
| `ParseExact(s, {std::string("a"), std::string("b")})` | ok |
58+
59+
Two `const char*` in braces match `std::basic_string(InputIt first, InputIt last)` over two
60+
**unrelated** pointers, so the single-format overload was a candidate — **and if it had ever won,
61+
the result would be undefined behaviour rather than a wrong answer.** That is what decided the fix
62+
rather than documenting the papercut.
63+
64+
`std::initializer_list<std::string>` overloads resolve it: a braced list binds to an
65+
`initializer_list` parameter by a **list-initialization sequence**, which outranks any user-defined
66+
conversion, so the dangerous candidate can no longer win. All five spellings now compile, and a
67+
case asserts the **unbraced** single-format overload is still reachable — the half a fix aimed only
68+
at the braced spelling could have broken.
69+
70+
## Evidence
71+
72+
Four mutations, **three caught**. **M3 is a proven equivalence, recorded at the site rather than
73+
counted**: with the empty-input guard removed the loop still runs, every format fails to match an
74+
empty input, and the fall-through returns the same outcome with the same message. **.NET's is an
75+
equivalence too** — its `s.Length == 0` arm and its all-formats-failed arm are both
76+
`SetBadDateTimeFailure`, one kind and one text — so the line is a statement of intent, kept because
77+
it is .NET's and because it says *why* an empty input cannot succeed without relying on every
78+
format's scanner to refuse it. One mutation was **invalid as first written** and reformulated
79+
rather than counted (`-Werror=unused-parameter`).
80+
81+
Gate: **17,732 / 38, 0 failed, 0 skipped** (+7; `SharpRuntimeTests_Core_Base` 6,141 → 6,148).
82+
Module graph **41 / 95**. Downstream: **zero sites** — the members did not exist.
83+
84+
## What #1944 asked for and did not get
85+
86+
The **span-like** shapes. `System::ReadOnlySpan<char>` exists in this port, and `std::string_view`
87+
is the idiomatic C++ counterpart, but neither is added: **every exact-parsing door here takes
88+
`const std::string&`**, and adding a second text representation beside it repeats exactly the
89+
overload hazard this ticket just closed, in a place where the wrong branch is silent rather than
90+
ambiguous. It is recorded here rather than taken, because it is a public-shape decision and the
91+
collection half — which is what every multi-format caller actually needs — is complete without it.

modules/core/include/System/DateOnly.hpp

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
// Copyright (c) Robert Vokac and contributors
33
// Portions based on .NET runtime API (MIT License, Copyright .NET Foundation and Contributors)
44
#pragma once
5+
#include <initializer_list>
6+
#include <vector>
57
#include "System/Globalization/DateTimeStyles.hpp"
68
#include "System/IFormatProvider.hpp"
79
#include <string>
@@ -243,6 +245,62 @@ namespace System {
243245
const System::IFormatProvider* provider,
244246
System::Globalization::DateTimeStyles style, DateOnly& result);
245247

248+
/**
249+
* @brief Parses @p input against the FIRST of @p formats that matches (#1944).
250+
*
251+
* C++ counterpart of .NET `DateOnly.ParseExact(string, string[], IFormatProvider, ...)`.
252+
*
253+
* @note **AN EMPTY ELEMENT ABORTS THE WHOLE LOOP RATHER THAN BEING SKIPPED** -- .NET
254+
* returns its bad-format-specifier failure immediately rather than trying the next
255+
* entry, and "skip it and carry on" is the plausible implementation that is wrong.
256+
*
257+
* @note **AN EMPTY COLLECTION IS A FORMAT FAILURE, NOT AN ARGUMENT ONE**, and .NET's
258+
* **null array** arm has no C++ counterpart here: the parameter is a
259+
* `const std::vector<std::string>&`, which cannot be null.
260+
*/
261+
[[nodiscard]] static DateOnly ParseExact(
262+
const std::string& input, const std::vector<std::string>& formats,
263+
const System::IFormatProvider* provider = nullptr,
264+
System::Globalization::DateTimeStyles style = System::Globalization::DateTimeStyles::None);
265+
266+
/** @brief Non-throwing counterpart of the multi-format ParseExact. */
267+
static bool TryParseExact(const std::string& input,
268+
const std::vector<std::string>& formats,
269+
const System::IFormatProvider* provider,
270+
System::Globalization::DateTimeStyles style, DateOnly& result);
271+
272+
/**
273+
* @brief `std::initializer_list` overload of the multi-format ParseExact (#1944).
274+
*
275+
* **THIS EXISTS TO KEEP A BRACED LIST OUT OF `std::string`'s ITERATOR-PAIR CONSTRUCTOR,
276+
* and the wrong branch is undefined behaviour rather than merely a wrong overload.**
277+
* Measured: without it, `ParseExact(s, {"a", "b"})` is **ambiguous** between the
278+
* single-format `(string, string)` -- where two `const char*` in braces match
279+
* `basic_string(InputIt first, InputIt last)` over two **unrelated** pointers -- and the
280+
* multi-format one. `{"one"}` is ambiguous too; three or more elements are not, and an
281+
* explicit `std::vector<std::string>{...}` never was.
282+
*
283+
* A braced list binds to an `initializer_list` parameter by a **list-initialization
284+
* sequence**, which outranks any user-defined conversion, so this overload takes it
285+
* unambiguously and the dangerous candidate can no longer win.
286+
*/
287+
[[nodiscard]] static DateOnly ParseExact(
288+
const std::string& input, std::initializer_list<std::string> formats,
289+
const System::IFormatProvider* provider = nullptr,
290+
System::Globalization::DateTimeStyles style = System::Globalization::DateTimeStyles::None) {
291+
return ParseExact(input, std::vector<std::string>(formats), provider,
292+
style);
293+
}
294+
295+
/** @brief `std::initializer_list` overload of the multi-format TryParseExact. */
296+
static bool TryParseExact(const std::string& input,
297+
std::initializer_list<std::string> formats,
298+
const System::IFormatProvider* provider,
299+
System::Globalization::DateTimeStyles style, DateOnly& result) {
300+
return TryParseExact(input, std::vector<std::string>(formats), provider,
301+
style, result);
302+
}
303+
246304
/** @brief Returns true if this date equals the specified date. */
247305
bool operator==(const DateOnly& o) const { return year_==o.year_ && month_==o.month_ && day_==o.day_; }
248306
/** @brief Returns true if this date does not equal the specified date. */

modules/core/include/System/DateTime.hpp

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
// Copyright (c) Robert Vokac and contributors
33
// Portions based on .NET runtime API (MIT License, Copyright .NET Foundation and Contributors)
44
#pragma once
5+
#include <initializer_list>
6+
#include <vector>
57

68
#include <string>
79
#include "System/DateTimeKind.hpp"
@@ -586,6 +588,72 @@ namespace System {
586588
DateTime& result,
587589
const System::ILocalTimeZone* zone = nullptr);
588590

591+
/**
592+
* @brief Parses @p input against the FIRST of @p formats that matches (#1944).
593+
*
594+
* C++ counterpart of .NET `DateTime.ParseExact(string, string[], IFormatProvider,
595+
* DateTimeStyles)`.
596+
*
597+
* @note **AN EMPTY ELEMENT ABORTS THE WHOLE LOOP RATHER THAN BEING SKIPPED**, which is the
598+
* rule a plausible implementation gets wrong: .NET returns its bad-format-specifier
599+
* failure immediately rather than trying the next entry.
600+
*
601+
* @note **AN EMPTY COLLECTION IS A FORMAT FAILURE, NOT AN ARGUMENT ONE** -- .NET's
602+
* `Format_NoFormatSpecifier` -- so a caller passing no formats gets
603+
* `FormatException`, which is easy to get wrong in the direction of
604+
* `ArgumentException`.
605+
*
606+
* @note .NET's **null formats array** raises `ArgumentNullException`; that arm has **no
607+
* C++ counterpart here** and is deliberately not reproduced, the parameter being a
608+
* `const std::vector<std::string>&` that cannot be null.
609+
*/
610+
[[nodiscard]] static DateTime ParseExact(
611+
const std::string& input, const std::vector<std::string>& formats,
612+
const System::IFormatProvider* provider = nullptr,
613+
System::Globalization::DateTimeStyles styles = System::Globalization::DateTimeStyles::None,
614+
const System::ILocalTimeZone* zone = nullptr);
615+
616+
/** @brief Non-throwing counterpart of the multi-format ParseExact. */
617+
static bool TryParseExact(const std::string& input,
618+
const std::vector<std::string>& formats,
619+
const System::IFormatProvider* provider,
620+
System::Globalization::DateTimeStyles styles, DateTime& result,
621+
const System::ILocalTimeZone* zone = nullptr);
622+
623+
/**
624+
* @brief `std::initializer_list` overload of the multi-format ParseExact (#1944).
625+
*
626+
* **THIS EXISTS TO KEEP A BRACED LIST OUT OF `std::string`'s ITERATOR-PAIR CONSTRUCTOR,
627+
* and the wrong branch is undefined behaviour rather than merely a wrong overload.**
628+
* Measured: without it, `ParseExact(s, {"a", "b"})` is **ambiguous** between the
629+
* single-format `(string, string)` -- where two `const char*` in braces match
630+
* `basic_string(InputIt first, InputIt last)` over two **unrelated** pointers -- and the
631+
* multi-format one. `{"one"}` is ambiguous too; three or more elements are not, and an
632+
* explicit `std::vector<std::string>{...}` never was.
633+
*
634+
* A braced list binds to an `initializer_list` parameter by a **list-initialization
635+
* sequence**, which outranks any user-defined conversion, so this overload takes it
636+
* unambiguously and the dangerous candidate can no longer win.
637+
*/
638+
[[nodiscard]] static DateTime ParseExact(
639+
const std::string& input, std::initializer_list<std::string> formats,
640+
const System::IFormatProvider* provider = nullptr,
641+
System::Globalization::DateTimeStyles styles = System::Globalization::DateTimeStyles::None,
642+
const System::ILocalTimeZone* zone = nullptr) {
643+
return ParseExact(input, std::vector<std::string>(formats), provider,
644+
styles, zone);
645+
}
646+
647+
/** @brief `std::initializer_list` overload of the multi-format TryParseExact. */
648+
static bool TryParseExact(const std::string& input,
649+
std::initializer_list<std::string> formats,
650+
const System::IFormatProvider* provider,
651+
System::Globalization::DateTimeStyles styles, DateTime& result,
652+
const System::ILocalTimeZone* zone = nullptr) {
653+
return TryParseExact(input, std::vector<std::string>(formats), provider,
654+
styles, result, zone);
655+
}
656+
589657
using Object::Equals;
590658

591659
/**

0 commit comments

Comments
 (0)