Qt
Internal/Contributor docs for the Qt SDK. Note: These are NOT official API docs; those are found at https://doc.qt.io/
Loading...
Searching...
No Matches
qtparsetimezone.cpp
Go to the documentation of this file.
1// Copyright (C) 2026 The Qt Company Ltd.
2// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
3// Qt-Security score:critical reason:data-parser
4#include "private/qtparsetimezone_p.h"
5
6#include "qdatetime.h"
7#include "qlocale.h"
8#include "private/qlocale_p.h"
9#include <QtCore/qloggingcategory.h>
10#include "qstring.h"
11#include "private/qtenvironmentvariables_p.h" // for tzName()
12#include "qtimezone.h"
13#if QT_CONFIG(timezone)
14# include "private/qtimezoneprivate_p.h"
15#endif
16
18
19using namespace Qt::StringLiterals;
20
21namespace {
22
23QList<QtParseTimeZone::ParsedZone>
24addMatch(QList<QtParseTimeZone::ParsedZone> &&matches,
25 QtParseTimeZone::ParsedZone &&match, [[maybe_unused]] bool gmtStart)
26{
27 // Input matches is sorted with x before y when isBetter(x, y); add our new
28 // entry just after the last that isBetter(than, it).
29 using namespace QtParseTimeZone;
30
31 // How discerning isBetter() can be depends on whether zones can have backends.
32 const auto isBetter = [
33#if QT_CONFIG(timezone)
34 // GMT may be recognized as various other things, but if named as such
35 // and supported by our backend, prefer it over others (of the same
36 // length) that aren't, with the exception of LocalTime:
37 newIsBackendGmt = gmtStart && match.size() == 3
38 && match.zone.timeSpec() == Qt::TimeZone && match.zone.id() == "GMT",
39#endif
40 newAddr = &match] (const ParsedZone &left, const ParsedZone &right) {
41 Q_ASSERT(left.startIndex == right.startIndex);
42 if (left.endIndex > right.endIndex)
43 return true;
44 if (left.endIndex < right.endIndex)
45 return false;
46 // For historical reasons (e.g. QTBUG-114575) we prefer local time over
47 // other ways of referring to the same zone:
48 if (left.zone.timeSpec() == Qt::LocalTime && right.zone.timeSpec() != Qt::LocalTime)
49 return true;
50 if (left.zone.timeSpec() != Qt::LocalTime && right.zone.timeSpec() == Qt::LocalTime)
51 return false;
52#if QT_CONFIG(timezone)
53 if (newIsBackendGmt) // The following is true exactly when left is the same as match:
54 return right.zone.timeSpec() != Qt::TimeZone || right.zone.id() != "GMT";
55#endif
56 return &right == newAddr;
57 };
58 const auto pos = std::upper_bound(matches.begin(), matches.end(), match, isBetter);
59 // Could condition the following on match not being a duplicate of pos[-1],
60 // for pos != begin(), but hopefully we simply aren't sending duplicates
61 // this way, anyway.
62 matches.insert(pos, match);
63 return std::move(matches);
64}
65
66#if QT_CONFIG(timezone)
67constexpr char zoneNamePunctuation[] = "+-./:_";
68
69QDateTimePrivate::DaylightStatus timeTypeToStatus(QTimeZone::TimeType type) {
70 using QDTP = QDateTimePrivate;
71 switch (type) {
72 case QTimeZone::GenericTime: return QDTP::UnknownDaylightTime;
73 case QTimeZone::StandardTime: return QDTP::StandardTime;
74 case QTimeZone::DaylightTime: return QDTP::DaylightTime;
75 }
76 Q_UNREACHABLE_RETURN(QDTP::UnknownDaylightTime);
77}
78
79auto matchIanaId(QStringView text)
80{
81 struct R {
82 QTimeZone zone;
83 qsizetype length = 0;
84 operator bool() const noexcept { return length > 0; }
85 };
86 // Collect up plausibly-valid characters; let QTimeZone work out what's
87 // truly valid.
88 const auto invalidZoneNameCharacter = [] (const QChar &c) {
89 static constexpr auto matcher = QtPrivate::makeCharacterSetMatch<zoneNamePunctuation>();
90 const auto cu = c.unicode();
91 return cu >= 127u || !(matcher.matches(uchar(cu)) || c.isLetterOrNumber());
92 };
93 qsizetype index = std::distance(text.cbegin(), std::find_if(text.cbegin(), text.cend(),
94 invalidZoneNameCharacter));
95 if (!index)
96 return R{};
97 Q_ASSERT(index <= text.size());
98 text.truncate(index);
99
100 // Limit name fragments (between slashes) to 20 characters.
101 // (Valid time-zone IDs are allowed up to 14 and Android has quirks up to 17.)
102 constexpr qsizetype MaxFragmentLength = 20;
103 // Limit number of fragments to six; no known zone name has more than four.
104 constexpr int MaxFragmentIndex = 5;
105 qsizetype lastSlash = -1;
106 int fragment = 1;
107 while (lastSlash < index) {
108 const qsizetype newToken = lastSlash + 1;
109 qsizetype slash = text.indexOf(u'/', newToken);
110 if (slash < 0)
111 slash = index; // i.e. the end of the candidate text
112 else if (++fragment > MaxFragmentIndex)
113 index = slash; // Truncate
114 if (slash - newToken > MaxFragmentLength)
115 index = newToken + MaxFragmentLength; // Truncate
116 // If any of those conditions was met, index <= slash, so this exits the loop:
117 lastSlash = slash;
118 }
119 // Only ASCII characters are valid, so we can now convert to Latin1.
120 QByteArray name = text.first(index).toLatin1();
121 // Subsequent truncation won't trigger reallocation, so is efficient despite
122 // the owning container.
123
124 // IANA includes a limited few three-letter abbreviations as IDs.
125 // Find longest IANA ID match:
126 for (; index >= 3; name.truncate(--index)) {
127 QTimeZone zone(name);
128 if (zone.isValid())
129 return R{zone, index};
130 }
131
132 // Not a known IANA ID.
133 return R{};
134}
135#endif // feature timezone
136
137auto matchSystemName(QStringView text, const QLocale &locale)
138{
139 using QDTP = QDateTimePrivate;
140 struct R {
141 qsizetype length = 0;
142 QDTP::DaylightStatus season = QDTP::UnknownDaylightTime;
143 operator bool() const noexcept { return length > 0; }
144 } best;
145 qTzSet();
146 // On MS-Win, at least when system zone is UTC, qTzName() can return empty.
147 for (int i = 0; i < 2; ++i) {
148 const QString zone(qTzName(i));
149 if (zone.size() > best.length && text.startsWith(zone))
150 best = { zone.size(), i ? QDTP::DaylightTime : QDTP::StandardTime };
151 }
152#if QT_CONFIG(timezone)
153 // Mimic each candidate QLocale::toString() could have used, to ensure round-trips work:
154 const auto consider = [text, &best](QStringView zone, QDTP::DaylightStatus season) {
155 if (text.startsWith(zone)) {
156 // UTC-based zone's displayName() only includes minutes if non-zero:
157 constexpr qsizetype utcSignHourWidth = 6, withMinutesWidth = 9;
158 if (withMinutesWidth > best.length && zone.size() == utcSignHourWidth
159 && zone.startsWith("UTC"_L1)
160 && text.sliced(utcSignHourWidth).startsWith(":00"_L1)) {
161 best = { withMinutesWidth, QDTP::UnknownDaylightTime };
162 } else if (zone.size() > best.length) {
163 best = { zone.size(), season };
164 }
165 }
166 };
167 /* QLocale::toString would skip this if locale == QLocale::system(), but we
168 might not be using the same system locale as whoever generated the text
169 we're parsing. So consider it anyway. */
170 if (const QTimeZone sys = QTimeZone::systemTimeZone(); sys.hasDaylightTime()) {
171 constexpr QTimeZone::TimeType types[] = {
172 QTimeZone::GenericTime, QTimeZone::StandardTime, QTimeZone::DaylightTime };
173 for (const auto timeType : types) {
174 consider(sys.displayName(timeType, QTimeZone::ShortName, locale),
175 timeTypeToStatus(timeType));
176 }
177 } else {
178 consider(sys.displayName(QTimeZone::GenericTime, QTimeZone::ShortName, locale),
179 QDTP::UnknownDaylightTime);
180 }
181#else
182 Q_UNUSED(locale);
183#endif
184 return best;
185}
186
187struct SizeOffset {
188 qsizetype length = 0;
189 int secondsEast = 0;
190 constexpr SizeOffset(qsizetype size, int offset) : length(size), secondsEast(offset) {}
191};
192
193// Locale-independent ISO 8601 offset forms
194QList<SizeOffset> matchIso8601(QStringView text, QtTemporalPattern::TemporalFieldFlags flags)
195{
196 constexpr int MaxOffsetHours
197 = (std::max)(-QTimeZone::MinUtcOffsetSecs, QTimeZone::MaxUtcOffsetSecs) / 3600;
198 QList<SizeOffset> matches;
199 using namespace QtTemporalPattern;
200 using namespace FieldGroup;
201 using Flag = TemporalFieldFlag;
202
203 if (flags.testFlag(Flag::AllowZSuffix) && text.startsWith(QLatin1Char('Z'))) {
204 matches.emplace_back(1, 0);
205 // No other ISO 8601 offset form starts with Z.
206 return matches;
207 }
208
209 qsizetype used = 0;
210 QStringView tail = text; // Invariant: is a prefix of text.sliced(used)
211 if (tail.startsWith(u"UTC")) {
212 if (!matchesFlagWithin(flags, Flag::AcceptUtcPrefix, UtcPrefixMask))
213 return matches;
214 used += 3;
215 tail = tail.sliced(3);
216 } else if (!matchesFlagWithin(flags, Flag::NeedNoUtcPrefix, UtcPrefixMask)) {
217 return matches;
218 }
219 const bool negate = tail.startsWith(u'-');
220 if (!negate && !tail.startsWith(u'+'))
221 return matches;
222 ++used;
223 tail = tail.sliced(1);
224
225 const auto extend = [&matches, negate](qsizetype length, int secondsEast) {
226 if (negate)
227 secondsEast = -secondsEast;
228 if (secondsEast >= QTimeZone::MinUtcOffsetSecs
229 && secondsEast <= QTimeZone::MaxUtcOffsetSecs) {
230 matches.emplace_back(length, secondsEast);
231 }
232 };
233
234 int hours = 0, minutes = 0, seconds = 0;
235 const bool zeroPad = flags.testFlag(Flag::ZeroPad);
236 constexpr TemporalFieldFlags WithColon = Flag::Verbal | Flag::Standalone;
237 qsizetype colon = tail.indexOf(u':');
238 if (colon == 0) // No digits in (first field of) offset.
239 return matches;
240
241 if (!matchesFlagsWithin(flags, WithColon, FormMask)) { // Colon forbidden.
242 if (colon > 0) {
243 // Treat as juxtaposed fields with cruft starting at the colon:
244 tail = tail.first(colon);
245 colon = -1;
246 }
247 } else if (!matchesFlagWithin(flags, Flag::Numeric, FormMask)) { // Colon required
248 if (colon > 2) {
249 // Too long for a single field. Treat as hour field followed by trailing
250 // cruft, since our colon is too late to separate it from a later field.
251 tail = tail.first(2);
252 colon = -1; // There is no longer a colon in tail.
253 } else if (colon < 0) {
254 // Lack of expected colon - we have, at most, an hour field:
255 if (tail.size() > 2)
256 tail = tail.first(2);
257 }
258 } // else: if a colon is there, read fields up to it.
259 // If we have a colon at the end of the hour field, each field must end in a
260 // colon. No field is wider than two digits, so a colon further out than
261 // that isn't the end of the hour field, just part of some dangling cruft.
262 const bool hasColon = colon > 0 && colon <= 2;
263 bool ok;
264 qsizetype fieldUsed = qMin(2, hasColon ? colon : tail.size());
265 hours = tail.first(fieldUsed).toInt(&ok);
266 if (!ok || hours > MaxOffsetHours || (zeroPad && fieldUsed < 2)) {
267 if (zeroPad) // Hour field must have full width.
268 return matches;
269 hours = tail.first(1).toInt(&ok);
270 fieldUsed = 1;
271 // Single-digit hour is only allowed in colon-separated form; if we
272 // don't have an actual colon, the parse must end after this field.
273 if (!ok)
274 return matches;
275 }
276 tail = tail.sliced(fieldUsed);
277 used += fieldUsed;
278
279 qsizetype fieldEnd[3] = { used, 0, 0 };
280 int fieldsSeen = 1; // Seen hour field
281 // If we're allowed more than just hour, see what we've got:
282 if ((flags & WidthMask) != QtTemporalPattern::TemporalFieldFlags{Flag::Narrow}) {
283 for (int i = 0; i < 2 && fieldUsed && !tail.isEmpty(); ++i) {
284 QStringView digits = tail;
285 qsizetype sepLen = 0;
286 if (hasColon || fieldUsed == 1) {
287 if (fieldUsed != colon)
288 break;
289 Q_ASSERT(tail.startsWith(u':'));
290 digits = digits.sliced(1);
291 sepLen = 1;
292 }
293 int &field = i ? seconds : minutes;
294 colon = hasColon && !i ? digits.indexOf(u':') : -1;
295 if (colon == 0) // Empty field
296 break;
297 if ((colon == -1 ? digits.size() : colon) < 2) // Not enough digits for field.
298 break;
299 field = digits.first(2).toInt(&ok);
300 if (!ok)
301 break;
302 fieldUsed = 2; // So next iteration sees that to compare to colon.
303 tail = tail.sliced(sepLen + fieldUsed);
304 used += sepLen + fieldUsed;
305 fieldEnd[fieldsSeen++] = used;
306 // Quit loop after 1st iteration unless accepting seconds field:
307 if (!i && !matchesFlagsWithin(flags, Flag::Wide | Flag::Short, WidthMask))
308 break;
309 }
310 }
311 // Check we got enough fields, add entries, with longer matches earlier:
312 switch (fieldsSeen) {
313 case 3: // Would have exited loop early unless:
314 Q_ASSERT(matchesFlagsWithin(flags, Flag::Wide | Flag::Short, WidthMask));
315 // TODO: if Wide, check for fractional part.
316 extend(fieldEnd[--fieldsSeen], (hours * 60 + minutes) * 60 + seconds);
317 Q_FALLTHROUGH();
318 case 2: // Hour and minute supplied.
319 if (!zeroPad || matchesFlagWithin(flags, Flag::Abbreviated, WidthMask))
320 extend(fieldEnd[fieldsSeen - 1], (hours * 60 + minutes) * 60);
321 --fieldsSeen;
322 Q_FALLTHROUGH();
323 case 1: // Only hour supplied: need Narrow if ZeroPad:
324 if (zeroPad && !matchesFlagWithin(flags, Flag::Narrow, WidthMask))
325 break;
326 extend(fieldEnd[--fieldsSeen], hours * 60 * 60);
327 }
328 return matches;
329}
330
331}
332
334
335/*!
336 \internal
337 \since 6.12
338 \namespace QtParseTimeZone
339 \brief A toolset for parsing time zone identification strings
340
341 A time zone may be identified by an offset from UTC or, in various ways, by
342 a name. This namespace provides a \l {QtParseTimeZone::}{prefix()} function
343 to parse an initial portion of a string as such an identifier, controlled by
344 configuration options provided by \l
345 {QtTemporalPattern::TemporalFieldFlags}, along with several combinations of
346 those options that select particular commonly-used choices.
347
348 The constants are of type \l {QtTemporalPattern::TemporalFieldFlags}:
349 \list
350
351 \li AnyOffsetForm Enables all offset options.
352 \li BasicDigitOnlyOffset The Qt 'tt' offset format: HH or HHmm, no
353 separator between the hour and minute fields, no UTC or GMT prefix,
354 just the sequence of digits.
355 \li BasicColonDigitOffset The Qt 'ttt' offset format: HH or HH:mm, fields
356 within the offset are separated by colons, there is no UTC or GMT
357 prefix.
358 \li AnyZoneName The Qt 'tttt' format: the IANA ID or localized long name
359 of the zone.
360 \li AllLegacyForm The Qt 't' format: any zone representation supported up
361 to Qt 6.10.
362 \li AnyZoneForm Enables all options.
363
364 \endlist
365*/
366// TODO: this is not, currently, quite true. The colon distinction is a myth.
367
368/*!
369 \internal
370 \since 6.12
371 \class QtParseTimeZone::ParsedZone
372 \brief Describes a text fragment representing a timezone.
373
374 Returned by functions that parse a timezone representation from a text. Its
375 member variables are:
376 \list
377
378 \li zone A timezone representing the result of parsing
379 \li timeType A \l QDateTimePrivate::DaylightStatus indicating the form in
380 which the zone is described by its representation
381 \li startIndex Parsed text offset of the start of the text matched
382 \li endIndex Parsed text offset of the end of the text matched
383
384 \endlist
385
386 The portion of the text that matched stretches from \c startIndex to \c
387 endIndex and can be obtained by passing the same text to \c used(). This
388 shall be empty if \c isEmpty() is \c true.
389
390 The \c zone describes the timezone matched. If \c isEmpty() is \c true, \c
391 zone shall be a lightweight time representation for local time, since a
392 timestamp with no specified zone is conventionally understood to be in local
393 time (although whose local time may be unclear). If this leaves a tail of
394 the text parsed that is otherwise not recognized, it may mean that the text
395 was malformed, or represented a timezone not recognized by the parser. If
396 the portion of the text matched takes a locale-appropriate form for a fixed
397 offset from UTC, \c zone shall be a lightweight time representation for UTC,
398 if the offset is zero, or for the specified offset from UTC. Otherwise, the
399 text matched identified a specific timezone (this only happens if feature \c
400 timezone is enabled) and \c zone is a timezone backed by system data.
401*/
402
403/*!
404 \internal
405 \since 6.12
406 Parses an initial portion of \a text as a timezone, as described by \a locale
407
408 The acceptable forms of a timezone text are controlled by \a flags.
409*/
410QList<ParsedZone> prefix(QStringView text, const QLocale &locale, qsizetype from,
411 QtTemporalPattern::TemporalFieldFlags flags)
412{
413 using QDTP = QDateTimePrivate;
414 QList<ParsedZone> matches;
415 if (from < 0 || from >= text.size())
416 return matches;
417
418 QStringView tail = text.sliced(from);
419 const auto includeMatch = [&matches, from, gmtStart = tail.startsWith(u"GMT")]
420 (qsizetype used, QTimeZone &&zone, QDTP::DaylightStatus type) {
421 Q_ASSERT(zone.isValid());
422 matches = addMatch(std::move(matches), {{from, from + used}, zone, type}, gmtStart);
423 };
424
425 using namespace QtTemporalPattern;
426 using namespace FieldGroup;
427 using Flag = TemporalFieldFlag;
428
429 if (matchesFlagWithin(flags, Flag::Iso8601, FieldGroup::LocalizationMask)) {
430 // Locale-independent offset forms:
431 const auto matches = matchIso8601(tail, flags);
432 for (const auto &match : matches) {
433 includeMatch(match.length,
434 QTimeZone::fromSecondsAheadOfUtc(match.secondsEast),
435 QDTP::UnknownDaylightTime);
436 }
437 }
438
439 // Locale-dependent forms:
440#if QT_CONFIG(timezone)
441 if (matchesFlagWithin(flags, Flag::LocalizedZone, FieldGroup::LocalizationMask)) {
442 const auto addPrefixIfMatch = [includeMatch] (QTimeZonePrivate::NamePrefixMatch &&prefix) {
443 if (prefix) {
444 includeMatch(prefix.nameLength, QTimeZone(prefix.ianaId),
445 timeTypeToStatus(prefix.timeType));
446 }
447 };
448 bool checkOffsetFallbacks = false;
449
450 if (matchesFlagWithin(flags, Flag::Numeric, FormMask)
451 && matchesFlagsWithin(flags, Flag::Wide | Flag::Short, WidthMask)) {
452 // TODO: have findOffsetPrefix() return a list:
453 addPrefixIfMatch(QTimeZonePrivate::findOffsetPrefix(tail, locale, flags));
454 checkOffsetFallbacks = true; // Might cover some corner cases differently:
455 }
456
457 // IANA after offset-as-such because we prefer offset from UTC
458 // representations over more complex backend representations:
459 if (matchesFlagWithin(flags, Flag::Standalone, FormMask)
460 && matchesFlagWithin(flags, Flag::Short, WidthMask)) {
461 if (auto match = matchIanaId(tail))
462 includeMatch(match.length, std::move(match.zone), QDTP::UnknownDaylightTime);
463 }
464 // ... but before long name, even though that may match some offset forms,
465 // but it only does that as a fall-back, so the IANA choice is better in
466 // that case.
467
468 if (matchesFlagWithin(flags, Flag::Verbal, FormMask)
469 && matchesFlagsWithin(flags, Flag::Wide | Flag::Short, WidthMask)) {
470 // TODO: findLongNamePrefix() would prefer to be first tried with a date-time.
471 addPrefixIfMatch(QTimeZonePrivate::findLongNamePrefix(tail, locale));
472 // (We don't want offset format to match 'tttt', so do need to limit this.)
473 // The final fall-back for QTZL's localeName() is a
474 // zoneOffsetFormat(,, Numeric | Abbreviated | NeedNoUtcPrefix | ZeroPad ,,):
475 checkOffsetFallbacks = true;
476 }
477
478 if (checkOffsetFallbacks) {
479 addPrefixIfMatch(QTimeZonePrivate::findNarrowOffsetPrefix(tail, locale));
480 addPrefixIfMatch(QTimeZonePrivate::findLongUtcPrefix(tail));
481 }
482 }
483#endif
484
485 if (flags.testFlag(Flag::LocalTimeName)) {
486 if (const auto sys = matchSystemName(tail, locale))
487 includeMatch(sys.length, QTimeZone(QTimeZone::LocalTime), sys.season);
488 }
489 if (text.sliced(from).startsWith(u"LMT")) {
490 // Local (solar) mean time: every zone falls back to this as
491 // abbreviation long enough ago, so we can't resolve it. Treat as local
492 // time, as there's no better way to interpret it.
493 includeMatch(3, QTimeZone(QTimeZone::LocalTime), QDTP::UnknownDaylightTime);
494 }
495
496 return matches;
497}
498
499// ParsedZone find(QStringView text, const QLocale &locale,
500// QtTemporalPattern::TemporalFieldFlags flags, qsizetype from) { }
501} // QtParseTimeZone
502
503QT_END_NAMESPACE
Combined button and popup list for selecting options.
A toolset for parsing time zone identification strings.
QList< ParsedZone > prefix(QStringView text, const QLocale &locale, qsizetype from, QtTemporalPattern::TemporalFieldFlags flags)