Skip to content

Commit 0fffe7d

Browse files
committed
feat(core): TimeSpan gains ParseExact, which it had in no spelling (#1943)
TimeSpanStyles sat in modules/globalization with nothing able to consume it -- #1997 group A-3's shape. It moved into Core.Base: #1940's shape C for the third time, again with not one #include line changed. The obvious implementation -- reuse InvariantExactDateTimeParser -- would be wrong in a way that passes most tests. .NET keeps TryParseByFormat apart from DateTimeParse, and the token table shows why: an UNQUOTED LITERAL IS AN ERROR here, so "hh:mm" is not a valid TimeSpan format and the colon must be quoted or escaped, while the date/time scanner matches an unquoted literal instead -- a shared scanner would silently accept a format .NET rejects. There is also no sign token at all; each component may appear once; d takes 1..8 digits for one specifier and exactly N for more, so a uniform rule gets days wrong and passes every hour/minute/second row; and f requires its digits where F does not. AssumeNegative is therefore the only route to a negative result, which is what makes the style load-bearing rather than decorative. The standard formats ignore it, as .NET does, because c carries its own sign. Bounds are per component, so "25" against "hh" fails rather than carrying into days. c/t/T implemented as one format under three names; g and G pinned absent, being the localized formats -- they need a culture decimal separator this port has no database for and optional components the custom scanner cannot express. An illegal style throws where a parse failure returns false, validating before the result is written. Nine mutations all caught; two invalid as first written and reformulated: M2's anchor never matched the file, M9's was rejected by -Werror. Gate 17,708/38 green. Graph 41/95. Zero downstream sites.
1 parent 5b693ef commit 0fffe7d

11 files changed

Lines changed: 653 additions & 2 deletions

File tree

CLAUDE.md

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

NEXT.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,44 @@
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 — #1943 (TimeSpan half): `TimeSpan` had no `ParseExact` in any spelling
143+
144+
**Gate: 17,708 / 38, 0 failed, 0 skipped** (+8, `Core_Base` 6,118 → 6,126). Graph 41/95, catalogue
145+
regenerated.
146+
147+
`TimeSpanStyles` existed in `modules/globalization` with **nothing able to consume it** — #1997
148+
A-3's shape. It moved into `Core.Base`: **#1940's shape C for the third time**, again with **not one
149+
`#include` line changed**.
150+
151+
**The obvious implementation — reuse the date/time scanner — would be wrong in a way that passes
152+
most tests.** .NET keeps `TryParseByFormat` apart from `DateTimeParse`, and the token table shows
153+
why: **an unquoted literal is an ERROR here**, so `"hh:mm"` is *not* a valid `TimeSpan` format and
154+
the colon must be quoted or escaped — the date/time scanner *matches* an unquoted literal, so a
155+
shared scanner would silently **accept a format .NET rejects**. There is also **no sign token at
156+
all**; each component may appear once; **`d`'s digit rule is not the others'** (one specifier means
157+
1..8 digits, more means exactly that many — a uniform rule gets days wrong and passes every
158+
hour/minute/second row); and **`f` requires its digits where `F` does not**.
159+
160+
So **`AssumeNegative` is the only route to a negative result** — `ParseExact("-01:30", "hh':'mm")`
161+
**fails** — which is what makes the style load-bearing rather than decorative. The standard formats
162+
ignore it, as .NET does. **Bounds are per component**, so `"25"` against `"hh"` fails rather than
163+
carrying into days.
164+
165+
`c`/`t`/`T` implemented; **`g` and `G` pinned absent** — the *localized* formats, needing a culture
166+
decimal separator this port has no database for **and** optional components the custom scanner
167+
cannot express.
168+
169+
Nine mutations, all caught. **Two were invalid as first written and reformulated rather than
170+
counted**: M2's anchor never matched the file (an absent anchor is a harness state, not a finding),
171+
M9's was rejected by `-Werror`.
172+
173+
`docs/Migration-TimeSpanParseExact.md`. Downstream zero sites.
174+
175+
**#1943 still has `DateTimeOffset::ParseExact`**, which needs a zone for the *no-offset* case.
176+
**An offset is not a time zone** — a format carrying an explicit offset would need none at all — so
177+
adding an offset token to the exact grammar is the route; it is recorded rather than taken because
178+
the default still needs #1942's answer.
179+
142180
## 2026-08-20 — #2415: the selective-component gate was red, and building into `/tmp`
143181

144182
**Gate unchanged at 17,700 / 38** — no production code was touched. This repairs a *gate*, and the

docs/ComponentCatalog.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ for maintainers and are not part of the consumer include surface.
6767
| `Security` | `modules/security` | interface | `Core.Base` |||| `System/Security/Authentication/AuthenticationException.hpp` |
6868
| `Security.Cryptography` | `modules/security-cryptography` | static | `Core.Base` |||| `System/Security/Cryptography/AuthenticationTagMismatchException.hpp` |
6969
| `Security.Cryptography.Random` | `modules/security-cryptography-random` | static | `Core.Base` ||| `bcrypt` on Windows (private) | `System/Security/Cryptography/RNGCryptoServiceProvider.hpp` |
70-
| `Xml` | `modules/xml` | static | `Core.Base`, `Uri` | `Diagnostics`, `TimeZone` | `Xml.Linq` | vendored tinyxml2 (public) | `System/Xml/ConformanceLevel.hpp` |
70+
| `Xml` | `modules/xml` | static | `Core.Base`, `Uri` | `Diagnostics`, `TimeZone` | `TimeZone`, `Xml.Linq` | vendored tinyxml2 (public) | `System/Xml/ConformanceLevel.hpp` |
7171
| `Xml.Linq` | `modules/xml-linq` | static | `Core.Base`, `Xml` |||| `System/Xml/Linq/Extensions.hpp` |
7272

7373
## Compatibility components
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
<!-- SPDX-License-Identifier: MIT -->
2+
<!-- Copyright (c) Robert Vokac and contributors -->
3+
4+
# `TimeSpan::ParseExact`#1943 (TimeSpan half)
5+
6+
**Purely additive, under SA-5.** No existing signature, layout, vtable, `noexcept` specification or
7+
accepted input changed; nothing needs migrating and no consumer rebuilds.
8+
9+
## The gap
10+
11+
`System::TimeSpan`'s entire parse surface was `Parse(s)` and `TryParse(s, result)`**there was no
12+
`ParseExact` in any spelling**, so a caller could not parse a duration against a stated layout.
13+
`System::Globalization::TimeSpanStyles` existed in `modules/globalization` and **nothing could
14+
consume it**, exactly the shape #1997 group A-3 found for `UriCreationOptions`.
15+
16+
`TimeSpanStyles.hpp` moved into `Core.Base`#1940's shape C for the third time (after
17+
`DateTimeFormatInfo` and `DateTimeStyles`), and again **not one `#include` line changed**, because
18+
module ownership is by logical path uniqueness. Graph unchanged at **41 / 95**; catalogue
19+
regenerated.
20+
21+
## Why this is a separate scanner, and why sharing one would be wrong
22+
23+
The obvious implementation reuses `InvariantExactDateTimeParser.hpp`. **It would be wrong in a way
24+
that passes most tests.** .NET keeps `TimeSpanParse.TryParseByFormat` apart from `DateTimeParse`,
25+
and the token table shows why:
26+
27+
* **An unquoted literal is an ERROR.** `TryParseByFormat`'s `switch` ends in
28+
`default: return result.SetInvalidStringFailure();`, so **`"hh:mm"` is not a valid `TimeSpan`
29+
format** — the colon must be `"hh':'mm"` or `"hh\:mm"`. The date/time scanner *matches* an
30+
unquoted literal against the input, so a shared scanner would silently **accept a format .NET
31+
rejects**.
32+
* **There is no sign token at all** — no `-`, no `+`.
33+
* **Each component may appear at most once**, tracked by five `seen*` flags.
34+
* **`d`'s digit rule is not the others'**: one specifier means **1..8** digits, more than one means
35+
**exactly** that many. A scanner written "exactly `tokenLen`" everywhere gets days wrong and
36+
passes every hour/minute/second row.
37+
* **`f` requires its digits where `F` makes them optional** — .NET calls the same reader for both
38+
and simply **ignores the result** for `F`.
39+
40+
## `AssumeNegative` is the only route to a negative result
41+
42+
Because the custom grammar has no sign token, `ParseExact("-01:30", "hh':'mm")` **fails** while
43+
`ParseExact("01:30", "hh':'mm", nullptr, AssumeNegative)` is minus ninety minutes. That is what
44+
makes `TimeSpanStyles` load-bearing rather than decorative, and it is the property a reader most
45+
expects to be wrong.
46+
47+
**The standard formats ignore the style**, as .NET does: `c` carries its own sign, so
48+
`AssumeNegative` must not flip a value that already said what it was. Mutation M6 makes it flip and
49+
is caught.
50+
51+
## Bounds are per component, not a total range
52+
53+
.NET's own constants (`TimeSpanParse.cs:59-62`): days ≤ 10675199, hours ≤ 23, minutes ≤ 59,
54+
seconds ≤ 59. So `"25"` against `"hh"` **fails** rather than carrying into days — the reading a
55+
total-range check would give. Mutation M4 makes it a total range and is caught.
56+
57+
## What is implemented, and what is pinned absent
58+
59+
| Specifier | State |
60+
|---|---|
61+
| `c`, `t`, `T` | implemented — one format under three names, delegating to the general `TryParse`, which is that same invariant constant grammar |
62+
| custom formats | implemented |
63+
| `g`, `G` | **pinned absent** |
64+
65+
`g` and `G` are .NET's **localized** standard formats and two things stop them: they need a
66+
culture's decimal separator, which this port has no database for (#2410's boundary), and their
67+
grammars have **optional components** (`g` is `[-][d':']h':'mm':'ss[.FFFFFFF]`) that the
68+
custom-format scanner cannot express — each would need its own hand-written arm. A later ticket
69+
adding them trips the pin.
70+
71+
An empty format and any other single character are **bad format specifiers**, as in .NET.
72+
73+
## A `Try*` method that throws
74+
75+
An illegal `TimeSpanStyles` **raises** where a parse failure returns false — .NET's own shape — and
76+
**validation runs before the result is written**, so a rejected style leaves the caller's variable
77+
untouched. That is two claims and each has its own assertion.
78+
79+
## Evidence
80+
81+
Nine mutations, **all caught**. Two were **invalid as first written and reformulated rather than
82+
counted**: M2's anchor did not match the file (a whitespace mismatch, so the edit never applied —
83+
an ambiguous or absent anchor is a harness state, not a finding), and M9's first spelling left a
84+
local unused and was rejected by `-Werror=unused-variable`.
85+
86+
Gate: **17,708 / 38, 0 failed, 0 skipped** (+8; `SharpRuntimeTests_Core_Base` 6,118 → 6,126).
87+
Module graph **41 / 95**, unchanged; catalogue regenerated. Downstream: **zero sites** in `cna` and
88+
`mobile-eggbert`.
89+
90+
## What #1943 still has left
91+
92+
`DateTimeOffset::ParseExact`. It needs a zone for the **no-offset** case: .NET's
93+
`DateTimeStyles.None` gives the result the **local** offset, and `Core.Base` cannot name a zone —
94+
the same decision #1942 is waiting on. **An offset is not a time zone**, so a format carrying an
95+
explicit offset would need no zone at all; adding an offset token to the exact grammar is the route,
96+
and it is recorded here rather than taken, because the no-offset default still needs the answer.
97+
`XmlConvert::ToDateTimeOffset(s, format)` composes the two today (#1945) in the module that *can*
98+
name a zone, and a later `DateTimeOffset::ParseExact` should **absorb** that body rather than sit
99+
beside it.

modules/globalization/include/System/Globalization/TimeSpanStyles.hpp renamed to modules/core/include/System/Globalization/TimeSpanStyles.hpp

File renamed without changes.

modules/core/include/System/TimeSpan.hpp

Lines changed: 54 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 "System/IFormatProvider.hpp"
6+
#include "System/Globalization/TimeSpanStyles.hpp"
57
#include <atomic>
68
#include <chrono>
79
#include <cmath>
@@ -449,6 +451,58 @@ namespace System {
449451
/** Tries to parse a TimeSpan string; returns false without throwing on failure. */
450452
static bool TryParse(const std::string& s, TimeSpan& result);
451453

454+
/**
455+
* @brief Parses @p input against exactly @p format.
456+
*
457+
* C++ counterpart of .NET `TimeSpan.ParseExact(string, string, IFormatProvider,
458+
* TimeSpanStyles)`, added by #1943.
459+
*
460+
* @note **THE CUSTOM GRAMMAR HAS NO SIGN TOKEN**, which is not an omission but .NET's
461+
* design: a negative result comes only from `TimeSpanStyles::AssumeNegative`. So
462+
* `ParseExact("-01:30", "hh':'mm")` **fails** -- the `-` matches nothing -- while
463+
* `ParseExact("01:30", "hh':'mm", nullptr, AssumeNegative)` is minus ninety minutes.
464+
*
465+
* @note **AN UNQUOTED LITERAL IS AN ERROR**, unlike in a `DateTime` exact format:
466+
* `"hh:mm"` is rejected and the colon must be written `"hh':'mm"` or `"hh\:mm"`.
467+
* .NET's `TryParseByFormat` ends its `switch` in `default: SetInvalidStringFailure`,
468+
* so this is the reference's rule rather than this port's strictness.
469+
*
470+
* @note **`g` AND `G` ARE DELIBERATELY NOT IMPLEMENTED AND ARE PINNED ABSENT.** They are
471+
* .NET's *localized* standard formats, and two things stop them here: they need a
472+
* culture's decimal separator, which this port has no database for (#2410's
473+
* boundary), and their grammars have **optional components** (`g` is
474+
* `[-][d':']h':'mm':'ss[.FFFFFFF]`) that the custom-format scanner cannot express --
475+
* each would need its own hand-written arm. `c`, `t` and `T` are implemented,
476+
* and they are the formats that round-trip `ToString()`.
477+
*
478+
* @param input The string to parse.
479+
* @param format A standard specifier (`c`, `t`, `T`) or a custom format string.
480+
* @param provider Reserved for parity; the grammar reads no culture-driven token.
481+
* @param styles `AssumeNegative` to make the result negative.
482+
* @throws System::FormatException if @p input does not match @p format.
483+
* @throws System::ArgumentException if @p styles is not a defined value.
484+
*/
485+
[[nodiscard]] static TimeSpan ParseExact(
486+
const std::string& input, const std::string& format,
487+
const System::IFormatProvider* provider = nullptr,
488+
System::Globalization::TimeSpanStyles styles =
489+
System::Globalization::TimeSpanStyles::None);
490+
491+
/**
492+
* @brief Non-throwing counterpart of ParseExact.
493+
*
494+
* @note **AN ILLEGAL STYLE STILL THROWS**, which is .NET's own shape for a `Try*` method:
495+
* a parse *failure* returns false, but an invalid style is a programming error and
496+
* is raised. Validation runs BEFORE @p result is written.
497+
*/
498+
static bool TryParseExact(const std::string& input, const std::string& format,
499+
TimeSpan& result);
500+
501+
/** @brief Non-throwing counterpart of ParseExact, with a provider and styles. */
502+
static bool TryParseExact(const std::string& input, const std::string& format,
503+
const System::IFormatProvider* provider,
504+
System::Globalization::TimeSpanStyles styles, TimeSpan& result);
505+
452506
public:
453507
/** Unary minus: returns the negated TimeSpan. */
454508
TimeSpan operator-() const;

0 commit comments

Comments
 (0)