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 int index = std::distance(text.cbegin(),
94 std::find_if(text.cbegin(), text.cend(), 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 // Limit number of fragments to six; no known zone name has more than four.
103 int lastSlash = -1;
104 int count = 0;
105 while (lastSlash < index) {
106 const int newToken = lastSlash + 1;
107 int slash = text.indexOf(u'/', newToken);
108 if (slash < 0)
109 slash = index; // i.e. the end of the candidate text
110 else if (++count > 5)
111 index = slash; // Truncate
112 if (slash - newToken > 20)
113 index = newToken + 20; // Truncate
114 // If any of those conditions was met, index <= slash, so this exits the loop:
115 lastSlash = slash;
116 }
117 // Only ASCII characters aren't invalid, so we can now convert to Latin1.
118 QByteArray name = text.first(index).toLatin1();
119 // Subsequent truncation won't trigger reallocation, so is efficient despite
120 // the owning container.
121
122 // Find longest IANA ID match:
123 for (; index > 3; name.truncate(--index)) {
124 QTimeZone zone(name);
125 if (zone.isValid())
126 return R{zone, index};
127 }
128 // GMT may be recognized as other things, but if it's the actual name given
129 // and our backend supports it, prefer the backend version over other forms.
130 if (index == 3 && name == "GMT") {
131 QTimeZone zone(name);
132 if (zone.isValid())
133 return R{zone, index};
134 }
135
136 // Not a known IANA ID.
137 return R{};
138}
139#endif // feature timezone
140
141auto matchSystemName(QStringView text, const QLocale &locale)
142{
143 using QDTP = QDateTimePrivate;
144 struct R {
145 qsizetype length = 0;
146 QDTP::DaylightStatus season = QDTP::UnknownDaylightTime;
147 operator bool() const noexcept { return length > 0; }
148 } best;
149 qTzSet();
150 // On MS-Win, at least when system zone is UTC, qTzName() can return empty.
151 for (int i = 0; i < 2; ++i) {
152 const QString zone(qTzName(i));
153 if (zone.size() > best.length && text.startsWith(zone))
154 best = { zone.size(), i ? QDTP::DaylightTime : QDTP::StandardTime };
155 }
156#if QT_CONFIG(timezone)
157 // Mimic each candidate QLocale::toString() could have used, to ensure round-trips work:
158 const auto consider = [text, &best](QStringView zone, QDTP::DaylightStatus season) {
159 if (text.startsWith(zone)) {
160 // UTC-based zone's displayName() only includes minutes if non-zero:
161 constexpr qsizetype utcSignHourWidth = 6, withMinutesWidth = 9;
162 if (withMinutesWidth > best.length && zone.size() == utcSignHourWidth
163 && zone.startsWith("UTC"_L1)
164 && text.sliced(utcSignHourWidth).startsWith(":00"_L1)) {
165 best = { withMinutesWidth, QDTP::UnknownDaylightTime };
166 } else if (zone.size() > best.length) {
167 best = { zone.size(), season };
168 }
169 }
170 };
171 /* QLocale::toString would skip this if locale == QLocale::system(), but we
172 might not be using the same system locale as whoever generated the text
173 we're parsing. So consider it anyway. */
174 if (const QTimeZone sys = QTimeZone::systemTimeZone(); sys.hasDaylightTime()) {
175 constexpr QTimeZone::TimeType types[] = {
176 QTimeZone::GenericTime, QTimeZone::StandardTime, QTimeZone::DaylightTime };
177 for (const auto timeType : types) {
178 consider(sys.displayName(timeType, QTimeZone::ShortName, locale),
179 timeTypeToStatus(timeType));
180 }
181 } else {
182 consider(sys.displayName(QTimeZone::GenericTime, QTimeZone::ShortName, locale),
183 QDTP::UnknownDaylightTime);
184 }
185#else
186 Q_UNUSED(locale);
187#endif
188 return best;
189}
190
191struct SizeOffset {
192 qsizetype length = 0;
193 int secondsEast = 0;
194 constexpr SizeOffset(qsizetype size, int offset) : length(size), secondsEast(offset) {}
195};
196
197// Locale-independent ISO 8601 offset forms
198QList<SizeOffset> matchIso8601(QStringView text, QtTemporalPattern::TemporalFieldFlags flags)
199{
200 constexpr int MaxOffsetHours
201 = (std::max)(-QTimeZone::MinUtcOffsetSecs, QTimeZone::MaxUtcOffsetSecs) / 3600;
202 QList<SizeOffset> matches;
203 using namespace QtTemporalPattern;
204 using namespace FieldGroup;
205 using Flag = TemporalFieldFlag;
206
207 if (flags.testFlag(Flag::AllowZSuffix) && text.startsWith(QLatin1Char('Z'))) {
208 matches.emplace_back(1, 0);
209 // No other ISO 8601 offset form starts with Z.
210 return matches;
211 }
212
213 qsizetype used = 0;
214 QStringView tail = text; // Invariant: is a prefix of text.sliced(used)
215 if (tail.startsWith(u"UTC")) {
216 if (!matchesFlagWithin(flags, Flag::AcceptUtcPrefix, UtcPrefixMask))
217 return matches;
218 used += 3;
219 tail = tail.sliced(3);
220 } else if (!matchesFlagWithin(flags, Flag::NeedNoUtcPrefix, UtcPrefixMask)) {
221 return matches;
222 }
223 const bool negate = tail.startsWith(u'-');
224 if (!negate && !tail.startsWith(u'+'))
225 return matches;
226 ++used;
227 tail = tail.sliced(1);
228
229 const auto extend = [&matches, negate](qsizetype length, int secondsEast) {
230 if (negate)
231 secondsEast = -secondsEast;
232 if (secondsEast >= QTimeZone::MinUtcOffsetSecs
233 && secondsEast <= QTimeZone::MaxUtcOffsetSecs) {
234 matches.emplace_back(length, secondsEast);
235 }
236 };
237
238 int hours = 0, minutes = 0, seconds = 0;
239 const bool zeroPad = flags.testFlag(Flag::ZeroPad);
240 constexpr TemporalFieldFlags WithColon = Flag::Verbal | Flag::Standalone;
241 qsizetype colon = tail.indexOf(u':');
242 if (colon == 0) // No digits in (first field of) offset.
243 return matches;
244
245 if (!matchesFlagsWithin(flags, WithColon, FormMask)) { // Colon forbidden.
246 if (colon > 0) {
247 // Treat as juxtaposed fields with cruft starting at the colon:
248 tail = tail.first(colon);
249 colon = -1;
250 }
251 } else if (!matchesFlagWithin(flags, Flag::Numeric, FormMask)) { // Colon required
252 if (colon > 2) {
253 // Too long for a single field. Treat as hour field followed by trailing
254 // cruft, since our colon is too late to separate it from a later field.
255 tail = tail.first(2);
256 colon = -1; // There is no longer a colon in tail.
257 } else if (colon < 0) {
258 // Lack of expected colon - we have, at most, an hour field:
259 if (tail.size() > 2)
260 tail = tail.first(2);
261 }
262 } // else: if a colon is there, read fields up to it.
263 // If we have a colon at the end of the hour field, each field must end in a
264 // colon. No field is wider than two digits, so a colon further out than
265 // that isn't the end of the hour field, just part of some dangling cruft.
266 const bool hasColon = colon > 0 && colon <= 2;
267 bool ok;
268 qsizetype fieldUsed = qMin(2, hasColon ? colon : tail.size());
269 hours = tail.first(fieldUsed).toInt(&ok);
270 if (!ok || hours > MaxOffsetHours || (zeroPad && fieldUsed < 2)) {
271 if (zeroPad) // Hour field must have full width.
272 return matches;
273 hours = tail.first(1).toInt(&ok);
274 fieldUsed = 1;
275 // Single-digit hour is only allowed in colon-separated form; if we
276 // don't have an actual colon, the parse must end after this field.
277 if (!ok)
278 return matches;
279 }
280 tail = tail.sliced(fieldUsed);
281 used += fieldUsed;
282
283 qsizetype fieldEnd[3] = { used, 0, 0 };
284 int fieldsSeen = 1; // Seen hour field
285 // If we're allowed more than just hour, see what we've got:
286 if ((flags & WidthMask) != QtTemporalPattern::TemporalFieldFlags{Flag::Narrow}) {
287 for (int i = 0; i < 2 && fieldUsed && !tail.isEmpty(); ++i) {
288 QStringView digits = tail;
289 qsizetype sepLen = 0;
290 if (hasColon || fieldUsed == 1) {
291 if (fieldUsed != colon)
292 break;
293 Q_ASSERT(tail.startsWith(u':'));
294 digits = digits.sliced(1);
295 sepLen = 1;
296 }
297 int &field = i ? seconds : minutes;
298 colon = hasColon && !i ? digits.indexOf(u':') : -1;
299 if (colon == 0) // Empty field
300 break;
301 if ((colon == -1 ? digits.size() : colon) < 2) // Not enough digits for field.
302 break;
303 field = digits.first(2).toInt(&ok);
304 if (!ok)
305 break;
306 fieldUsed = 2; // So next iteration sees that to compare to colon.
307 tail = tail.sliced(sepLen + fieldUsed);
308 used += sepLen + fieldUsed;
309 fieldEnd[fieldsSeen++] = used;
310 // Quit loop after 1st iteration unless accepting seconds field:
311 if (!i && !matchesFlagsWithin(flags, Flag::Wide | Flag::Short, WidthMask))
312 break;
313 }
314 }
315 // Check we got enough fields, add entries, with longer matches earlier:
316 switch (fieldsSeen) {
317 case 3: // Would have exited loop early unless:
318 Q_ASSERT(matchesFlagsWithin(flags, Flag::Wide | Flag::Short, WidthMask));
319 // TODO: if Wide, check for fractional part.
320 extend(fieldEnd[--fieldsSeen], (hours * 60 + minutes) * 60 + seconds);
321 Q_FALLTHROUGH();
322 case 2: // Hour and minute supplied.
323 if (!zeroPad || matchesFlagWithin(flags, Flag::Abbreviated, WidthMask))
324 extend(fieldEnd[fieldsSeen - 1], (hours * 60 + minutes) * 60);
325 --fieldsSeen;
326 Q_FALLTHROUGH();
327 case 1: // Only hour supplied: need Narrow if ZeroPad:
328 if (zeroPad && !matchesFlagWithin(flags, Flag::Narrow, WidthMask))
329 break;
330 extend(fieldEnd[--fieldsSeen], hours * 60 * 60);
331 }
332 return matches;
333}
334
335}
336
338
339/*!
340 \internal
341 \since 6.12
342 \namespace QtParseTimeZone
343 \brief A toolset for parsing time zone identification strings
344
345 A time zone may be identified by an offset from UTC or, in various ways, by
346 a name. This namespace provides a \l {QtParseTimeZone::}{prefix()} function
347 to parse an initial portion of a string as such an identifier, controlled by
348 configuration options provided by \l
349 {QtTemporalPattern::TemporalFieldFlags}, along with several combinations of
350 those options that select particular commonly-used choices.
351
352 The constants are of type \l {QtTemporalPattern::TemporalFieldFlags}:
353 \list
354
355 \li AnyOffsetForm Enables all offset options.
356 \li BasicDigitOnlyOffset The Qt 'tt' offset format: HH or HHmm, no
357 separator between the hour and minute fields, no UTC or GMT prefix,
358 just the sequence of digits.
359 \li BasicColonDigitOffset The Qt 'ttt' offset format: HH or HH:mm, fields
360 within the offset are separated by colons, there is no UTC or GMT
361 prefix.
362 \li AnyZoneName The Qt 'tttt' format: the IANA ID or localized long name
363 of the zone.
364 \li AllLegacyForm The Qt 't' format: any zone representation supported up
365 to Qt 6.10.
366 \li AnyZoneForm Enables all options.
367
368 \endlist
369*/
370// TODO: this is not, currently, quite true. The colon distinction is a myth.
371
372/*!
373 \internal
374 \since 6.12
375 \class QtParseTimeZone::ParsedZone
376 \brief Describes a text fragment representing a timezone.
377
378 Returned by functions that parse a timezone representation from a text. Its
379 member variables are:
380 \list
381
382 \li zone A timezone representing the result of parsing
383 \li timeType A \l QDateTimePrivate::DaylightStatus indicating the form in
384 which the zone is described by its representation
385 \li startIndex Parsed text offset of the start of the text matched
386 \li endIndex Parsed text offset of the end of the text matched
387
388 \endlist
389
390 The portion of the text that matched stretches from \c startIndex to \c
391 endIndex and can be obtained by passing the same text to \c used(). This
392 shall be empty if \c isEmpty() is \c true.
393
394 The \c zone describes the timezone matched. If \c isEmpty() is \c true, \c
395 zone shall be a lightweight time representation for local time, since a
396 timestamp with no specified zone is conventionally understood to be in local
397 time (although whose local time may be unclear). If this leaves a tail of
398 the text parsed that is otherwise not recognized, it may mean that the text
399 was malformed, or represented a timezone not recognized by the parser. If
400 the portion of the text matched takes a locale-appropriate form for a fixed
401 offset from UTC, \c zone shall be a lightweight time representation for UTC,
402 if the offset is zero, or for the specified offset from UTC. Otherwise, the
403 text matched identified a specific timezone (this only happens if feature \c
404 timezone is enabled) and \c zone is a timezone backed by system data.
405*/
406
407/*!
408 \internal
409 \since 6.12
410 Parses an initial portion of \a text as a timezone, as described by \a locale
411
412 The acceptable forms of a timezone text are controlled by \a flags.
413*/
414QList<ParsedZone> prefix(QStringView text, const QLocale &locale, qsizetype from,
415 QtTemporalPattern::TemporalFieldFlags flags)
416{
417 using QDTP = QDateTimePrivate;
418 QList<ParsedZone> matches;
419 if (from < 0 || from >= text.size())
420 return matches;
421
422 QStringView tail = text.sliced(from);
423 const auto includeMatch = [&matches, from, gmtStart = tail.startsWith(u"GMT")]
424 (qsizetype used, QTimeZone &&zone, QDTP::DaylightStatus type) {
425 Q_ASSERT(zone.isValid());
426 matches = addMatch(std::move(matches), {{from, from + used}, zone, type}, gmtStart);
427 };
428
429 using namespace QtTemporalPattern;
430 using namespace FieldGroup;
431 using Flag = TemporalFieldFlag;
432
433 if (matchesFlagWithin(flags, Flag::Iso8601, FieldGroup::LocalizationMask)) {
434 // Locale-independent offset forms:
435 const auto matches = matchIso8601(tail, flags);
436 for (const auto &match : matches) {
437 includeMatch(match.length,
438 QTimeZone::fromSecondsAheadOfUtc(match.secondsEast),
439 QDTP::UnknownDaylightTime);
440 }
441 }
442
443 // Locale-dependent forms:
444#if QT_CONFIG(timezone)
445 if (matchesFlagWithin(flags, Flag::LocalizedZone, FieldGroup::LocalizationMask)) {
446 const auto addPrefixIfMatch = [includeMatch] (QTimeZonePrivate::NamePrefixMatch &&prefix) {
447 if (prefix) {
448 includeMatch(prefix.nameLength, QTimeZone(prefix.ianaId),
449 timeTypeToStatus(prefix.timeType));
450 }
451 };
452 bool checkOffsetFallbacks = false;
453
454 if (matchesFlagWithin(flags, Flag::Numeric, FormMask)
455 && matchesFlagsWithin(flags, Flag::Wide | Flag::Short, WidthMask)) {
456 // TODO: have findOffsetPrefix() return a list:
457 addPrefixIfMatch(QTimeZonePrivate::findOffsetPrefix(tail, locale, flags));
458 checkOffsetFallbacks = true; // Might cover some corner cases differently:
459 }
460
461 // IANA after offset-as-such because we prefer offset from UTC
462 // representations over more complex backend representations:
463 if (matchesFlagWithin(flags, Flag::Standalone, FormMask)
464 && matchesFlagWithin(flags, Flag::Short, WidthMask)) {
465 if (auto match = matchIanaId(tail))
466 includeMatch(match.length, std::move(match.zone), QDTP::UnknownDaylightTime);
467 }
468 // ... but before long name, even though that may match some offset forms,
469 // but it only does that as a fall-back, so the IANA choice is better in
470 // that case.
471
472 if (matchesFlagWithin(flags, Flag::Verbal, FormMask)
473 && matchesFlagsWithin(flags, Flag::Wide | Flag::Short, WidthMask)) {
474 // TODO: findLongNamePrefix() would prefer to be first tried with a date-time.
475 addPrefixIfMatch(QTimeZonePrivate::findLongNamePrefix(tail, locale));
476 // (We don't want offset format to match 'tttt', so do need to limit this.)
477 // The final fall-back for QTZL's localeName() is a
478 // zoneOffsetFormat(,, Numeric | Abbreviated | NeedNoUtcPrefix | ZeroPad ,,):
479 checkOffsetFallbacks = true;
480 }
481
482 if (checkOffsetFallbacks) {
483 addPrefixIfMatch(QTimeZonePrivate::findNarrowOffsetPrefix(tail, locale));
484 addPrefixIfMatch(QTimeZonePrivate::findLongUtcPrefix(tail));
485 }
486 }
487#endif
488
489 if (flags.testFlag(Flag::LocalTimeName)) {
490 if (const auto sys = matchSystemName(tail, locale))
491 includeMatch(sys.length, QTimeZone(QTimeZone::LocalTime), sys.season);
492 }
493
494 return matches;
495}
496
497// ParsedZone find(QStringView text, const QLocale &locale,
498// QtTemporalPattern::TemporalFieldFlags flags, qsizetype from) { }
499} // QtParseTimeZone
500
501QT_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)