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