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