• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

kshetline / tubular_time / #46

08 Jul 2025 05:22AM UTC coverage: 93.302% (+7.3%) from 86.043%
#46

push

kshetline
Update .gitignore.

2099 of 2319 branches covered (90.51%)

Branch coverage included in aggregate %.

3278 of 3444 relevant lines covered (95.18%)

44603.29 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

85.73
/src/format-parse.ts
1
import {
1✔
2
  DateAndTime, enEras, enMonths, enMonthsShort, enWeekdays, enWeekdaysMin, enWeekdaysShort, getDatePart,
3
  getDateValue, parseTimeOffset, setFormatter
4
} from './common';
5
import { DateTime } from './date-time';
1✔
6
import { abs, floor, mod } from '@tubular/math';
1✔
7
import { ILocale } from './i-locale';
8
import { clone, convertDigitsToAscii, flatten, forEach, isArray, isEqual, isNumber, isString, last, toNumber } from '@tubular/util';
1✔
9
import {
1✔
10
  checkDtfOptions, getMeridiems, getMinDaysInWeek, getOrdinals, getStartOfWeek, getWeekend,
11
  hasIntlDateTime, hasPriorityMeridiems, normalizeLocale
12
} from './locale-data';
13
import { Timezone } from './timezone';
1✔
14
import DateTimeFormat = Intl.DateTimeFormat;
1✔
15
import DateTimeFormatOptions = Intl.DateTimeFormatOptions;
16

17
const shortOpts = { Y: 'year', M: 'month', D: 'day', w: 'weekday', h: 'hour', m: 'minute', s: 'second', z: 'timeZoneName',
1✔
18
                    ds: 'dateStyle', ts: 'timeStyle', e: 'era' };
19
const shortOptValues = { f: 'full', m: 'medium', n: 'narrow', s: 'short', l: 'long', dd: '2-digit', d: 'numeric' };
1✔
20
const styleOptValues = { F: 'full', L: 'long', M: 'medium', S: 'short' };
1✔
21
const patternTokens = /({[A-Za-z0-9/_]+?!?}|V|v|R|r|I[FLMSx][FLMS]?|MMMM~?|MMM~?|MM~?|Mo|M~?|Qo|Q|DDDD|DDD|Do|DD~?|D~?|dddd|ddd|do|dd|d|E|e|ww|wo|w|WW|Wo|W|YYYYYY|yyyyyy|YYYY~?|yyyy|YY|yy|Y~?|y~?|N{1,5}|n|gggg|gg|GGGG|GG|A|a|HH|H|hh|h|kk|k|mm|m|ss|s|LTS|LT|LLLL|llll|LLL|lll|LL|ll|L|l|S+|ZZZ|zzz|ZZ|zz|Z|z|XT|xt|XX|xx|X|x)/g;
1✔
22
const cachedLocales: Record<string, ILocale> = {};
1✔
23
const invalidZones = new Set<string>();
1✔
24
const warnedZones = new Set<string>();
1✔
25

26
let allNumeric: RegExp;
27
let dateMarkCheck: RegExp;
28

29
try {
1✔
30
  // Make sure Unicode character classes work.
31
  allNumeric = /^\p{Nd}+$/u;
1✔
32
  allNumeric.test('7१');
1✔
33
  dateMarkCheck = /\x80(?=[\p{L}\p{N}])/gu;
1✔
34
}
35
catch {
36
  allNumeric = /^\d+$/;
×
37
  dateMarkCheck = /\x80(?=[a-z0-9])/g;
×
38
}
39

40
export function newDateTimeFormat(locale?: string | string[], options?: DateTimeFormatOptions): DateTimeFormat {
1✔
41
  options = options && checkDtfOptions(options);
18,384✔
42

43
  for (let i = 0; i < 2; ++i) {
18,384✔
44
    let orig = options;
18,389✔
45

46
    if (options.dateStyle || options.timeStyle) {
18,389✔
47
      const standardOptions = resolveFormatDetails(locale, options.dateStyle, options.timeStyle);
10,391✔
48
      let changes = 0;
10,391✔
49

50
      orig = clone(options);
10,391✔
51
      delete options.dateStyle;
10,391✔
52
      delete options.timeStyle;
10,391✔
53

54
      forEach(standardOptions as any, (key, value) => {
10,391✔
55
        if (options[key] == null)
55,066✔
56
          options[key] = value;
55,063✔
57
        else if (options[key] !== value)
3✔
58
          ++changes;
1✔
59
      });
60

61
      forEach(orig as any, key => changes += +(orig[key] !== standardOptions[key] &&
36,802✔
62
        ['day', 'era', 'fractionalSecondDigits', 'hour', 'minute', 'month', 'second', 'timeZoneName', 'weekday', 'year'].includes(key)));
63

64
      if (changes === 0 && i !== 1)
10,391✔
65
        options = orig;
10,386✔
66
    }
67

68
    try {
18,389✔
69
      return new DateTimeFormat(locale, options);
18,389✔
70
    }
71
    catch {
72
      options = orig;
9✔
73
    }
74
  }
75

76
  return new DateTimeFormat(locale, options);
4✔
77
}
78

79
function formatEscape(s: string): string {
80
  let result = '';
29,880✔
81
  let inAlpha = false;
29,880✔
82

83
  s.split('').forEach(c => {
29,880✔
84
    if (/[~a-z[]/i.test(c)) {
45,602✔
85
      if (!inAlpha) {
3,997✔
86
        inAlpha = true;
1,887✔
87
        result += '[';
1,887✔
88
      }
89
    }
90
    else if (inAlpha && c.trim().length > 0 && c.charCodeAt(0) < 128) {
41,605✔
91
      inAlpha = false;
227✔
92
      result += ']';
227✔
93
      result = result.replace(/(\s+)]$/, ']$1');
227✔
94
    }
95

96
    result += c;
45,602✔
97
  });
98

99
  if (inAlpha) {
29,880✔
100
    result += ']';
1,660✔
101
    result = result.replace(/(\s+)]$/, ']$1');
1,660✔
102
  }
103

104
  return result;
29,880✔
105
}
106

107
const CACHE_LIMIT = 500;
1✔
108
const cachedParts = new Map<string, string[]>();
1✔
109
const cachedPartsStripped = new Map<string, string[]>();
1✔
110

111
export function decomposeFormatString(format: string, stripDateMarks = false): string[] {
1✔
112
  const cache = (stripDateMarks ? cachedPartsStripped : cachedParts);
11,561✔
113
  let parts: (string | string[])[] = cache.get(format);
11,561✔
114

115
  if (parts)
11,561✔
116
    return parts as string[];
10,093✔
117
  else
118
    parts = [];
1,468✔
119

120
  let inLiteral = true;
1,468✔
121
  let inBraces = false;
1,468✔
122
  let literal = '';
1,468✔
123
  let token = '';
1,468✔
124

125
  for (const ch of format.split('')) {
1,468✔
126
    if (/[~a-z]/i.test(ch) || (inBraces && ch === '[')) {
36,646✔
127
      if (inBraces)
23,415✔
128
        literal += ch;
1,272✔
129
      else if (inLiteral) {
22,143✔
130
        parts.push(literal);
9,640✔
131
        literal = '';
9,640✔
132
        token = ch;
9,640✔
133
        inLiteral = false;
9,640✔
134
      }
135
      else
136
        token += ch;
12,503✔
137
    }
138
    else if (ch === '[') {
13,231✔
139
      inBraces = true;
607✔
140

141
      if (!inLiteral) {
607✔
142
        if (stripDateMarks)
47✔
143
          token = token.replace(/~$/, '');
45✔
144

145
        parts.push(token);
47✔
146
        token = '';
47✔
147
        inLiteral = true;
47✔
148
      }
149
    }
150
    else if (inBraces && ch === ']')
12,624✔
151
      inBraces = false;
607✔
152
    else {
153
      if (!inLiteral) {
12,017✔
154
        if (stripDateMarks && token.endsWith('~')) {
8,222✔
155
          token = token.slice(0, -1);
3✔
156
          literal += ' ';
3✔
157
        }
158

159
        parts.push(token);
8,222✔
160
        token = '';
8,222✔
161
        inLiteral = true;
8,222✔
162
      }
163

164
      literal += ch;
12,017✔
165
    }
166
  }
167

168
  if ((inLiteral && literal) || (!inLiteral && token))
1,468✔
169
    parts.push(literal || token);
1,468✔
170

171
  for (let i = 1; i < parts.length; i += 2)
1,468✔
172
    parts[i] = (parts[i] as string).split(patternTokens);
9,640✔
173

174
  parts.forEach((part, index) => {
1,468✔
175
    if (index % 2 === 0)
19,377✔
176
      return;
9,737✔
177

178
    if (part.length === 3 && !part[0] && !part[2])
9,640✔
179
      parts[index] = part[1];
9,606✔
180
    else {
181
      parts[index - 1] += part[0];
34✔
182
      parts[index + 1] = last(part as string[]) + (parts[index + 1] ?? '');
34✔
183
      parts[index] = part.slice(1, part.length - 1);
34✔
184
    }
185
  });
186

187
  parts = flatten(parts);
1,468✔
188

189
  if (cache.size >= CACHE_LIMIT)
1,468✔
190
    cache.clear();
2✔
191

192
  cache.set(format, parts as string[]);
1,468✔
193

194
  return parts as string[];
1,468✔
195
}
196

197
function parseDateTimeFormatMods(s: string): DateTimeFormatOptions {
198
  s = s.replace(/\b([-_a-z0-9]+)\b/ig, '"$1"');
2✔
199

200
  try {
2✔
201
    return JSON.parse(s);
2✔
202
  }
203
  catch {}
204

205
  return null;
×
206
}
207

208
function isLetter(char: string, checkDot = false): boolean {
×
209
  // This custom test works out better than the \p{L} character class for parsing purposes here.
210
  return (checkDot && char === '.') ||
13,628✔
211
    /^[A-Za-zÀ-ÖØ-öø-ˁˆ-ˑˠ-ˤˬˮ\u0300-\u036FΑ-ΡΣ-ϔА-ҀҊ-ԯ\u05D0-\u05E9\u0620-\u065F\u066E-\u066F\u0671-\u06D3\u06D5\u06E5-\u06E6\u06EE-\u06EF\u06FA-\u06FC\u06FF\u0904-\u0939\u0F00-\u0F14\u0F40-\u0FBC\u1000-\u103F]/.test(char);
212
}
213

214
function isCased(s: string): boolean {
215
  return s.toLowerCase() !== s.toUpperCase();
11✔
216
}
217

218
function timeMatch(dt: DateTime, locale: ILocale): boolean {
219
  const format = locale.dateTimeFormats.check as DateTimeFormat;
3,762✔
220

221
  if (!format)
3,762✔
222
    return false;
2✔
223

224
  const fields = format.formatToParts(dt.epochMillis);
3,760✔
225
  const wt = dt.wallTime;
3,760✔
226

227
  return wt.hrs === getDateValue(fields, 'hour') &&
3,760✔
228
         wt.min === getDateValue(fields, 'minute') &&
229
         wt.sec === getDateValue(fields, 'second');
230
}
231

232
export function format(dt: DateTime, fmt: string, localeOverride?: string | string[]): string {
1✔
233
  if (!dt.valid)
7,769!
234
    return '##Invalid_Date##';
×
235

236
  const currentLocale = normalizeLocale(localeOverride ?? dt.locale);
7,769✔
237
  const localeNames = !hasIntlDateTime ? 'en' : currentLocale;
7,769!
238
  const locale = getLocaleInfo(localeNames);
7,769✔
239
  const cjk = /^(ja|ko|zh)/.test(locale.name);
7,769✔
240
  const ko = /^ko/.test(locale.name);
7,769✔
241
  const dateMarks = cjk ? ko ? ['년', '월', '일'] : ['年', '月', '日'] : ['\x80', '\x80', '\x80'];
7,769✔
242
  let usesDateMarks = false;
7,769✔
243
  const zeroAdj = locale.zeroDigit.charCodeAt(0) - 48;
7,769✔
244
  const toNum = (n: number | string, pad = 1): string => {
7,769✔
245
    if (n == null || (isNumber(n) && isNaN(n)))
27,551!
246
      return '?'.repeat(pad);
×
247
    else
248
      return n.toString().padStart(pad, '0').replace(/\d/g, ch => String.fromCharCode(ch.charCodeAt(0) + zeroAdj));
66,892✔
249
  };
250

251
  const dtfMods: DateTimeFormatOptions[] = [];
7,769✔
252

253
  fmt = fmt.replace(/(\bI[FLMSx][FLMS]?)({[^}]+})?/g, (_match, $1, $2) => {
7,769✔
254
    if ($2)
3,762✔
255
      dtfMods.push(parseDateTimeFormatMods($2));
2✔
256
    else
257
      dtfMods.push(null);
3,760✔
258

259
    return $1;
3,762✔
260
  });
261

262
  const parts = decomposeFormatString(fmt);
7,769✔
263
  const result: string[] = [];
7,769✔
264
  const wt = dt.wallTime;
7,769✔
265
  const year = wt.y;
7,769✔
266
  const eraYear = abs(year) + (year <= 0 ? 1 : 0);
7,769✔
267
  const month = wt.m;
7,769✔
268
  const quarter = floor((month + 2) / 3);
7,769✔
269
  const day = wt.d;
7,769✔
270
  const hour = wt.hrs;
7,769✔
271
  const h = (hour === 0 ? 12 : hour <= 12 ? hour : hour - 12);
7,769✔
272
  const K = (hour < 12 ? hour : hour - 12);
7,769✔
273
  const k = (hour === 0 ? 24 : hour);
7,769✔
274
  const min = wt.min;
7,769✔
275
  const sec = wt.sec;
7,769✔
276
  const dayOfWeek = dt.getDayOfWeek();
7,769✔
277
  const zoneName = dt.timezone.zoneName;
7,769✔
278

279
  for (let i = 0; i < parts.length; i += 2) {
7,769✔
280
    result.push(parts[i]);
39,310✔
281

282
    let field = parts[i + 1];
39,310✔
283
    let dateMark = 0;
39,310✔
284

285
    if (field == null)
39,310✔
286
      break;
3,915✔
287
    else if (field.endsWith('~')) {
35,395✔
288
      dateMark = -1;
28✔
289
      field = field.slice(0, -1);
28✔
290
      usesDateMarks = true;
28✔
291
    }
292

293
    if (!invalidZones.has(zoneName) &&
35,395✔
294
        ((/^[LlZzI]/.test(field) && locale.cachedTimezone !== zoneName) ||
295
         (hasIntlDateTime && isEqual(locale.dateTimeFormats, {})))) {
296
      try {
176✔
297
        generatePredefinedFormats(locale, zoneName);
176✔
298
      }
299
      catch (e) {
300
        if (/invalid time zone/i.test(e.message))
2✔
301
          invalidZones.add(zoneName);
2✔
302
      }
303
    }
304

305
    // noinspection FallThroughInSwitchStatementJS
306
    switch (field) {
35,395!
307
      case 'YYYYYY': // long year, always signed
308
      case 'yyyyyy':
309
        result.push((year < 0 ? '-' : '+') + toNum(abs(year), 6));
×
310
        break;
×
311

312
      case 'YYYY': // year, padded to at least 4 digits, signed if negative or > 9999
313
      case 'yyyy':
314
      case 'Y':
315
        result.push((year < 0 ? '-' : year <= 9999 ? '' : field === 'Y' ? '+' : '') + toNum(abs(year), 4));
3,931✔
316
        dateMark = dateMark && 1;
3,931✔
317
        break;
3,931✔
318

319
      case 'YY': // 2-digit year
320
      case 'yy':
321
        result.push(toNum(mod(abs(year), 100), 2));
2✔
322
        break;
2✔
323

324
      case 'y': // Era year, never signed, min value 1.
325
        result.push(toNum(eraYear));
11✔
326
        dateMark = dateMark && 1;
11✔
327
        break;
11✔
328

329
      case 'GGGG': // ISO-week year
330
      case 'GG':
331
        result.push((wt.yw < 0 ? '-' : year <= 9999 ? '' : field === 'GGGG' ? '+' : '') +
13!
332
          toNum(field.length === 2 ? abs(wt.yw) % 100 : abs(wt.yw), field.length));
13!
333
        break;
13✔
334

335
      case 'gggg': // Locale-week year
336
      case 'gg':
337
        result.push((wt.ywl < 0 ? '-' : year <= 9999 ? '' : field === 'gggg' ? '+' : '') +
8!
338
          toNum(field.length === 2 ? abs(wt.ywl) % 100 : abs(wt.ywl), field.length));
8!
339
        break;
8✔
340

341
      case 'Qo': // Quarter ordinal
342
        result.push(locale.ordinals[quarter]);
1✔
343
        break;
1✔
344

345
      case 'Q': // Quarter
346
        result.push(toNum(quarter));
1✔
347
        break;
1✔
348

349
      case 'MMMM': // Long textual month
350
        result.push(locale.months[month - 1]);
15✔
351
        dateMark = dateMark && 2;
15✔
352
        break;
15✔
353

354
      case 'MMM': // Short textual month
355
        result.push(locale.monthsShort[month - 1]);
15✔
356
        dateMark = dateMark && 2;
15✔
357
        break;
15✔
358

359
      case 'MM': // 2-digit month
360
        result.push(toNum(month, 2));
3,915✔
361
        dateMark = dateMark && 2;
3,915✔
362
        break;
3,915✔
363

364
      case 'Mo': // Month ordinal
365
        result.push(locale.ordinals[month]);
1✔
366
        break;
1✔
367

368
      case 'M': // Numerical month
369
        result.push(toNum(month));
3✔
370
        dateMark = dateMark && 2;
3✔
371
        break;
3✔
372

373
      case 'WW': // ISO week number
374
      case 'W':
375
        result.push(toNum(wt.w, field === 'WW' ? 2 : 1));
13!
376
        break;
13✔
377

378
      case 'ww': // Locale week number
379
      case 'w':
380
        result.push(toNum(wt.wl, field === 'ww' ? 2 : 1));
8!
381
        break;
8✔
382

383
      case 'DD': // 2-digit day of month
384
        result.push(toNum(day, 2));
3,921✔
385
        dateMark = dateMark && 3;
3,921✔
386
        break;
3,921✔
387

388
      case 'Do': // Day-of-month ordinal
389
        result.push(locale.ordinals[day]);
×
390
        break;
×
391

392
      case 'D': // Day-of-month number
393
        result.push(toNum(day));
14✔
394
        dateMark = dateMark && 3;
14!
395
        break;
14✔
396

397
      case 'dddd': // Long textual day of week
398
        result.push(locale.weekdays[dayOfWeek]);
1✔
399
        break;
1✔
400

401
      case 'ddd': // Short textual day of week
402
        result.push(locale.weekdaysShort[dayOfWeek]);
1✔
403
        break;
1✔
404

405
      case 'dd': // Minimal textual day of week
406
        result.push(locale.weekdaysMin[dayOfWeek]);
×
407
        break;
×
408

409
      case 'do': // Day-of-week ordinal
410
        result.push(locale.ordinals[dayOfWeek]);
1✔
411
        break;
1✔
412

413
      case 'd': // Day-of-week number
414
        result.push(toNum(dayOfWeek));
×
415
        break;
×
416

417
      case 'E': // Day-of-week ISO
418
        result.push(toNum(wt.dw));
14✔
419
        break;
14✔
420

421
      case 'e': // Day-of-week locale
422
        result.push(toNum(wt.dwl));
9✔
423
        break;
9✔
424

425
      case 'HH': // Two-digit 00-23 hour
426
        result.push(toNum(hour, 2));
3,925✔
427
        break;
3,925✔
428

429
      case 'H': // Numeric 0-23 hour
430
        result.push(toNum(hour));
1✔
431
        break;
1✔
432

433
      case 'hh': // Two-digit 01-12 hour
434
        result.push(toNum(h, 2));
3✔
435
        break;
3✔
436

437
      case 'h':// Numeric 1-12 hour
438
        result.push(toNum(h));
5✔
439
        break;
5✔
440

441
      case 'KK': // Two-digit 00-11 hour (needs AM/PM qualification)
442
        result.push(toNum(K, 2));
×
443
        break;
×
444

445
      case 'K': // Numeric 0-11 hour (needs AM/PM qualification)
446
        result.push(toNum(K));
×
447
        break;
×
448

449
      case 'kk': // Two-digit 01-24 hour
450
        result.push(toNum(k, 2));
×
451
        break;
×
452

453
      case 'k': // Numeric 1-24 hour
454
        result.push(toNum(k));
×
455
        break;
×
456

457
      case 'mm': // Two-digit minute
458
        result.push(toNum(min, 2));
3,934✔
459
        break;
3,934✔
460

461
      case 'm': // Numeric minute
462
        result.push(toNum(min));
×
463
        break;
×
464

465
      case 'ss': // Two-digit second
466
        result.push(toNum(sec, 2));
3,916✔
467
        break;
3,916✔
468

469
      case 's': // Numeric second
470
        result.push(toNum(sec));
×
471
        break;
×
472

473
      case 'A': // AM/PM indicator (may have more than just two forms)
474
      case 'a':
475
        {
476
          const values = locale.meridiemAlt ?? locale.meridiem;
10!
477
          const dayPartsForHour = values[values.length === 2 ? floor(hour / 12) : hour];
10✔
478

479
          // If there is no case distinction between the first two forms, use the first form
480
          // (the rest are there for parsing, not formatting).
481
          if (dayPartsForHour.length === 1 ||
10✔
482
              (!isCased(dayPartsForHour[0]) && !isCased(dayPartsForHour[0])))
483
            result.push(dayPartsForHour[0]);
3✔
484
          else
485
            result.push(dayPartsForHour[field === 'A' && dayPartsForHour.length > 1 ? 1 : 0]);
7!
486
        }
487
        break;
10✔
488

489
      case 'XX': // Epoch 1970-01-01 00:00 seconds
490
        result.push(dt.epochSeconds.toString());
×
491
        break;
×
492

493
      case 'xx': // Epoch 1970-01-01 00:00 milliseconds
494
        result.push(dt.epochMillis.toString());
×
495
        break;
×
496

497
      case 'XT': // Epoch 1970-01-01 00:00 TAI seconds
498
        result.push(dt.taiSeconds.toString());
×
499
        break;
×
500

501
      case 'xt': // Epoch 1970-01-01 00:00 TAI milliseconds
502
        result.push(dt.taiSeconds.toString());
×
503
        break;
×
504

505
      case 'X': // Epoch 1970-01-01 00:00 UTC seconds
506
        result.push(dt.utcSeconds.toString());
×
507
        break;
×
508

509
      case 'x': // Epoch 1970-01-01 00:00 UTC milliseconds
510
        result.push(dt.utcMillis.toString());
×
511
        break;
×
512

513
      case 'LLLL': // Various Moment.js-style shorthand date/time formats
514
      case 'llll':
515
      case 'LLL':
516
      case 'lll':
517
      case 'LTS':
518
      case 'LT':
519
      case 'LL':
520
      case 'll':
521
      case 'L':
522
      case 'l':
523
        {
524
          const localeFormat = locale.dateTimeFormats[field];
16✔
525

526
          if (localeFormat == null)
16!
527
            result.push(`[${field}?]`);
×
528
          else if (isString(localeFormat))
16!
529
            result.push(format(dt, localeFormat, localeOverride));
×
530
          else
531
            result.push(localeFormat.format(dt.epochMillis));
16✔
532
        }
533
        break;
16✔
534

535
      case 'ZZZ': // As IANA zone name, if possible
536
        if (zoneName !== 'OS') {
5!
537
          result.push(zoneName);
5✔
538
          break;
5✔
539
        }
540
        else if (hasIntlDateTime) {
×
541
          result.push(DateTimeFormat().resolvedOptions().timeZone);
×
542
          break;
×
543
        }
544

545
      case 'zzz':  // As long zone name (e.g. "Pacific Daylight Time"), if possible
546
        if (zoneName === 'TAI') {
5!
547
          result.push('Temps Atomique International');
×
548
          break;
×
549
        }
550
        else if (hasIntlDateTime && locale.dateTimeFormats.Z instanceof DateTimeFormat) {
5✔
551
          result.push(getDatePart(locale.dateTimeFormats.Z, dt.epochMillis, 'timeZoneName'));
5✔
552
          break;
5✔
553
        }
554

555
      case 'zz':  // As zone acronym (e.g. EST, PDT, AEST), if possible
556
      case 'z':
557
        if (zoneName !== 'TAI' && hasIntlDateTime && locale.dateTimeFormats.z instanceof DateTimeFormat) {
10✔
558
          result.push(getDatePart(locale.dateTimeFormats.z, dt.epochMillis, 'timeZoneName'));
9✔
559
          break;
9✔
560
        }
561
        else if (invalidZones.has(zoneName)) {
1!
562
          result.push(dt.timezone.getDisplayName(dt.epochMillis));
×
563
          break;
×
564
        }
565
        else if (zoneName !== 'OS') {
1!
566
          result.push(zoneName);
1✔
567
          break;
1✔
568
        }
569
        else
570
          field = 'Z';
×
571

572
      case 'ZZ': // Zone as UTC offset
573
      case 'Z':
574
        if (zoneName === 'TAI')
3,903✔
575
          result.push(Timezone.formatUtcOffset(dt.wallTime.deltaTai, field === 'ZZ'));
6✔
576
        else
577
          result.push(dt.timezone.getFormattedOffset(dt.epochMillis, field === 'ZZ'));
3,897✔
578
        break;
3,903✔
579

580
      case 'V':
581
      case 'v':
582
        result.push(Timezone.getDstSymbol(wt.dstOffset) + (wt.dstOffset === 0 && field === 'V' ? ' ' : ''));
45!
583
        break;
45✔
584

585
      case 'R':
586
      case 'r':
587
        result.push(wt.occurrence === 2 ? '\u2082' : field === 'R' ? ' ' : ''); // Subscript 2
42!
588
        break;
42✔
589

590
      case 'n':
591
        if (year < 1)
7✔
592
          result.push(locale.eras[0]);
4✔
593
        else if (result.length > 0 && last(result).endsWith(' '))
3✔
594
          result[result.length - 1] = last(result).trimEnd();
3✔
595
        break;
7✔
596

597
      default:
598
        if (field.startsWith('N'))
7,670✔
599
          result.push(locale.eras[(year < 1 ? 0 : 1) + (field.length === 4 ? 2 : 0)]);
4✔
600
        else if (field.startsWith('I')) {
7,666✔
601
          if (hasIntlDateTime) {
3,762!
602
            const formatKey = field + (dtfMods ? JSON.stringify(dtfMods) : '');
3,762!
603
            let intlFormat = locale.dateTimeFormats[formatKey] as DateTimeFormat;
3,762✔
604

605
            if (!intlFormat) {
3,762✔
606
              const options: DateTimeFormatOptions = {};
1,888✔
607
              const dtfMod = dtfMods.splice(0, 1)[0];
1,888✔
608

609
              if (dtfMod)
1,888✔
610
                Object.assign(options, dtfMod);
2✔
611

612
              options.calendar = 'gregory';
1,888✔
613

614
              const zone = convertDigitsToAscii(zoneName);
1,888✔
615
              let $: RegExpExecArray;
616

617
              if (zone === 'TAI')
1,888!
618
                options.timeZone = 'UTC';
×
619
              else if (($ = /^(?:GMT|UTC?)([-+])(\d\d(?::?\d\d))/.exec(zone))) {
1,888!
620
                options.timeZone = 'Etc/GMT' + ($[1] === '-' ? '+' : '-') + $[2].replace(/^0+(?=\d)|:|00$/g, '');
×
621

622
                if (!Timezone.has(options.timeZone))
×
623
                  delete options.timeZone;
×
624
              }
625
              else if (zone !== 'OS')
1,888✔
626
                options.timeZone = (zone === 'UT' ? 'UTC' : zone);
1,888✔
627

628
              if (field.charAt(1) !== 'x')
1,888✔
629
                options.dateStyle = styleOptValues[field.charAt(1)];
1,886✔
630

631
              if (field.length > 2)
1,888✔
632
                options.timeStyle = styleOptValues[field.charAt(2)];
1,884✔
633

634
              try {
1,888✔
635
                locale.dateTimeFormats[formatKey] = intlFormat = newDateTimeFormat(localeNames, options);
1,888✔
636
              }
637
              catch {
638
                if (!warnedZones.has(options.timeZone)) {
2✔
639
                  console.warn('Timezone "%s" not recognized', options.timeZone);
2✔
640
                  warnedZones.add(options.timeZone);
2✔
641
                }
642

643
                delete options.timeZone;
2✔
644
                locale.dateTimeFormats[formatKey] = intlFormat = newDateTimeFormat(localeNames, options);
2✔
645
              }
646
            }
647

648
            if (timeMatch(dt, locale))
3,762✔
649
              result.push(intlFormat.format(dt.epochMillis));
3,760✔
650
            else {
651
              // Favor @tubular/time timezone offsets over those derived from Intl.
652
              let intlFormatAlt = locale.dateTimeFormats['_' + field] as string;
2✔
653

654
              if (!intlFormatAlt)
2✔
655
                intlFormatAlt = locale.dateTimeFormats['_' + field] = analyzeFormat(currentLocale, intlFormat);
2✔
656

657
              result.push(format(dt, intlFormatAlt, localeOverride));
2✔
658
            }
659
          }
660
          else {
661
            let intlFormat = '';
×
662

663
            switch (field.charAt(1)) {
×
664
              case 'F': intlFormat = 'dddd, MMMM D, YYYY'; break;
×
665
              case 'L': intlFormat = 'MMMM D, YYYY'; break;
×
666
              case 'M': intlFormat = 'MMM D, YYYY'; break;
×
667
              case 'S': intlFormat = 'M/D/YY'; break;
×
668
            }
669

670
            if (intlFormat && /..[FLMS]/.test(field))
×
671
              intlFormat += ', ';
×
672

673
            switch (field.charAt(2)) {
×
674
              case 'F':
675
              case 'L': intlFormat += 'h:mm:ss A zz'; break;
×
676
              case 'M': intlFormat += 'h:mm:ss A'; break;
×
677
              case 'S': intlFormat += 'h:mm A'; break;
×
678
            }
679

680
            result.push(format(dt, intlFormat));
×
681
          }
682
        }
683
        else if (field.startsWith('S'))
3,904!
684
          result.push(toNum(wt.millis.toString().padStart(3, '0').substr(0, field.length), field.length));
3,904✔
685
        else
686
          result.push('??');
×
687
    }
688

689
    if (dateMark)
35,395✔
690
      result.push(dateMarks[dateMark - 1] + (ko ? '\x80' : ''));
28✔
691
  }
692

693
  let formatted = result.join('');
7,769✔
694

695
  if (usesDateMarks) {
7,769✔
696
    if (cjk)
12✔
697
      dateMarks.forEach(mark => formatted = formatted.replace(new RegExp(mark.repeat(2)), mark));
27✔
698

699
    if (ko || !cjk)
12✔
700
      formatted = formatted.replace(dateMarkCheck, ' ').replace(/\x80/g, '');
5✔
701
  }
702

703
  return formatted;
7,769✔
704
}
705

706
setFormatter(format);
1✔
707

708
function quickFormat(localeNames: string | string[], timezone: string, opts: any): DateTimeFormat {
709
  const options: DateTimeFormatOptions = { calendar: 'gregory' };
10,061✔
710
  let $: RegExpExecArray;
711

712
  localeNames = normalizeLocale(localeNames);
10,061✔
713

714
  if (timezone === 'DATELESS' || timezone === 'ZONELESS' || timezone === 'TAI')
10,061✔
715
    options.timeZone = 'UTC';
78✔
716
  else if (($ = /^(?:GMT|UTC?)([-+])(\d\d(?::?\d\d))/.exec(timezone))) {
9,983✔
717
    options.timeZone = 'Etc/GMT' + ($[1] === '-' ? '+' : '-') + $[2].replace(/^0+(?=\d)|:|00$/g, '');
39✔
718

719
    if (!Timezone.has(options.timeZone))
39✔
720
      delete options.timeZone;
39✔
721
  }
722
  else if (timezone !== 'OS')
9,944✔
723
    options.timeZone = (timezone === 'UT' ? 'UTC' : timezone);
9,944✔
724

725
  Object.keys(opts).forEach(key => {
10,061✔
726
    const value = shortOptValues[opts[key]] ?? opts[key];
16,602✔
727
    key = shortOpts[key] ?? key;
16,602✔
728
    options[key] = value;
16,602✔
729
  });
730

731
  try {
10,061✔
732
    return newDateTimeFormat(localeNames, options);
10,061✔
733
  }
734
  catch (e) {
735
    if (/invalid time zone/i.test(e.message)) {
2✔
736
      const aliases = Timezone.getAliasesForZone(options.timeZone);
2✔
737

738
      aliases.forEach(zone => {
2✔
739
        try {
4✔
740
          options.timeZone = zone;
4✔
741
          return newDateTimeFormat(localeNames, options);
4✔
742
        }
743
        catch {}
744
      });
745
    }
746

747
    throw e;
2✔
748
  }
749
}
750

751
// Find the shortest case-insensitive version of each string in the array that doesn't match
752
// the starting characters of any other item in the array.
753
function shortenItems(items: string[]): string[] {
754
  items = items.map(item => item.toLowerCase().replace(/\u0307/g, ''));
2,952✔
755

756
  for (let i = 0; i < items.length; ++i) {
246✔
757
    for (let j = 1; j < items[i].length; ++j) {
2,952✔
758
      const item = items[i].substr(0, j);
5,097✔
759
      let matched = false;
5,097✔
760

761
      for (let k = 0; k < items.length && !matched; ++k)
5,097✔
762
        matched = (k !== i && items[k].startsWith(item));
39,545✔
763

764
      if (!matched) {
5,097✔
765
        items[i] = item;
2,354✔
766
        break;
2,354✔
767
      }
768
    }
769
  }
770

771
  return items;
246✔
772
}
773

774
function getLocaleInfo(localeNames: string | string[]): ILocale {
775
  const joinedNames = isArray(localeNames) ? localeNames.join(',') : localeNames;
11,561✔
776
  const locale: ILocale = cachedLocales[joinedNames] ?? {} as ILocale;
11,561✔
777

778
  if (locale && Object.keys(locale).length > 0)
11,561✔
779
    return locale;
11,438✔
780

781
  const fmt = (opts: any): DateTimeFormat => quickFormat(localeNames, 'UTC', opts);
7,797✔
782

783
  locale.name = isArray(localeNames) ? localeNames.join(',') : localeNames;
123✔
784

785
  if (hasIntlDateTime) {
123!
786
    locale.months = [];
123✔
787
    locale.monthsShort = [];
123✔
788
    const narrow: string[] = [];
123✔
789
    let format: DateTimeFormat;
790
    const fullTimeFormat = new DateTimeFormat(normalizeLocale(locale.name), { timeStyle: 'full', timeZone: 'UTC' });
123✔
791

792
    for (let month = 1; month <= 12; ++month) {
123✔
793
      const date = Date.UTC(2021, month - 1, 1);
1,476✔
794
      let longMonth: string;
795

796
      format = fmt({ ds: 'l' });
1,476✔
797
      longMonth = getDatePart(format, date, 'month');
1,476✔
798

799
      if (allNumeric.test(longMonth)) {
1,476✔
800
        const altForm = fmt({ M: 'l' }).format(date);
48✔
801

802
        if (!allNumeric.test(altForm))
48✔
803
          longMonth = altForm;
48✔
804
      }
805

806
      locale.months.push(longMonth);
1,476✔
807
      format = fmt({ ds: 'm' });
1,476✔
808
      locale.monthsShort.push(getDatePart(format, date, 'month'));
1,476✔
809
      format = fmt({ M: 'n' });
1,476✔
810
      narrow.push(getDatePart(format, date, 'month'));
1,476✔
811
    }
812

813
    if (isEqual(locale.months, locale.monthsShort) && new Set(narrow).size === 12 && narrow.find(m => !/^\d+$/.test(m)))
123✔
814
      locale.monthsShort = narrow;
1✔
815

816
    locale.monthsMin = shortenItems(locale.months);
123✔
817
    locale.monthsShortMin = shortenItems(locale.monthsShort);
123✔
818

819
    locale.weekdays = [];
123✔
820
    locale.weekdaysShort = [];
123✔
821
    locale.weekdaysMin = [];
123✔
822
    locale.meridiemAlt = [];
123✔
823

824
    for (let day = 3; day <= 9; ++day) {
123✔
825
      const date = Date.UTC(2021, 0, day);
861✔
826

827
      format = fmt({ ds: 'f' });
861✔
828
      locale.weekdays.push(getDatePart(format, date, 'weekday'));
861✔
829
      format = fmt({ w: 's' });
861✔
830
      locale.weekdaysShort.push(getDatePart(format, date, 'weekday'));
861✔
831
      format = fmt({ w: 'n' });
861✔
832
      locale.weekdaysMin.push(getDatePart(format, date, 'weekday'));
861✔
833
    }
834

835
    // If weekdaysMin are so narrow that there are non-unique names, try either 2 or 3 characters from weekdaysShort.
836
    for (let len = 2; len < 4 && new Set(locale.weekdaysMin).size < 7; ++len)
123✔
837
      locale.weekdaysMin = locale.weekdaysShort.map(name => name.substr(0, len));
637✔
838

839
    const hourForms = new Set<string>();
123✔
840

841
    format = fmt({ h: 'd', hourCycle: 'h12' });
123✔
842

843
    for (let hour = 0; hour < 24; ++hour) {
123✔
844
      const date = Date.UTC(2021, 0, 1, hour,  0, 0);
2,952✔
845
      const value = getDatePart(format, date, 'dayPeriod');
2,952✔
846
      const lcValue = value.toLowerCase();
2,952✔
847
      let newHourForm = value;
2,952✔
848
      const newMeridiems = [];
2,952✔
849

850
      if (value === lcValue)
2,952✔
851
        newMeridiems.push(value);
1,608✔
852
      else
853
        newMeridiems.push(lcValue, value);
1,344✔
854

855
      const fullValue = getDatePart(fullTimeFormat, date, 'dayPeriod');
2,952✔
856
      const lcFullValue = fullValue?.toLowerCase();
2,952✔
857

858
      if (fullValue && fullValue !== value) {
2,952✔
859
        newHourForm += ',' + fullValue;
2,054✔
860

861
        if (fullValue === lcFullValue)
2,054!
862
          newMeridiems.push(fullValue);
2,054✔
863
        else
864
          newMeridiems.push(lcFullValue, fullValue);
×
865
      }
866

867
      hourForms.add(newHourForm);
2,952✔
868
      locale.meridiemAlt.push(newMeridiems);
2,952✔
869
    }
870

871
    if (hourForms.size < 3) {
123✔
872
      locale.meridiemAlt.splice(13, 11);
122✔
873
      locale.meridiemAlt.splice(1, 11);
122✔
874
    }
875

876
    locale.eras = [getDatePart(fmt({ y: 'n', e: 's' }), Date.UTC(-1, 0, 1), 'era')];
123✔
877
    locale.eras.push(getDatePart(fmt({ y: 'n', e: 's' }), Date.UTC(1, 0, 1), 'era'));
123✔
878
    locale.eras.push(getDatePart(fmt({ y: 'n', e: 'l' }), Date.UTC(-1, 0, 1), 'era'));
123✔
879
    locale.eras.push(getDatePart(fmt({ y: 'n', e: 'l' }), Date.UTC(1, 0, 1), 'era'));
123✔
880

881
    locale.zeroDigit = fmt({ m: 'd' }).format(0);
123✔
882
  }
883
  else {
884
    locale.eras = enEras;
×
885
    locale.months = enMonths;
×
886
    locale.monthsMin = shortenItems(locale.months);
×
887
    locale.monthsShort = enMonthsShort;
×
888
    locale.monthsShortMin = shortenItems(locale.monthsShort);
×
889
    locale.weekdays = enWeekdays;
×
890
    locale.weekdaysShort = enWeekdaysShort;
×
891
    locale.weekdaysMin = enWeekdaysMin;
×
892
    locale.zeroDigit = '0';
×
893
  }
894

895
  locale.dateTimeFormats = {};
123✔
896
  locale.meridiem = getMeridiems(localeNames);
123✔
897
  locale.startOfWeek = getStartOfWeek(localeNames);
123✔
898
  locale.minDaysInWeek = getMinDaysInWeek(localeNames);
123✔
899
  locale.weekend = getWeekend(localeNames);
123✔
900
  locale.ordinals = getOrdinals(localeNames);
123✔
901
  locale.parsePatterns = {};
123✔
902

903
  if (hasPriorityMeridiems(localeNames)) {
123✔
904
    const temp = locale.meridiem;
24✔
905
    locale.meridiem = locale.meridiemAlt;
24✔
906
    locale.meridiemAlt = temp;
24✔
907
  }
908

909
  cachedLocales[joinedNames] = locale;
123✔
910

911
  return locale;
123✔
912
}
913

914
function generatePredefinedFormats(locale: ILocale, timezone: string): void {
915
  const fmt = (opts: any): DateTimeFormat => quickFormat(locale.name, timezone, opts);
2,264✔
916

917
  locale.cachedTimezone = timezone;
176✔
918
  locale.dateTimeFormats = {};
176✔
919

920
  if (hasIntlDateTime) {
176!
921
    locale.dateTimeFormats.LLLL = fmt({ Y: 'd', M: 'l', D: 'd', w: 'l', h: 'd', m: 'dd' }); // Thursday, September 4, 1986 8:30 PM
176✔
922
    locale.dateTimeFormats.llll = fmt({ Y: 'd', M: 's', D: 'd', w: 's', h: 'd', m: 'dd' }); // Thu, Sep 4, 1986 8:30 PM
174✔
923
    locale.dateTimeFormats.LLL = fmt({ Y: 'd', M: 'l', D: 'd', h: 'd', m: 'dd' }); // September 4, 1986 8:30 PM
174✔
924
    locale.dateTimeFormats.lll = fmt({ Y: 'd', M: 's', D: 'd', h: 'd', m: 'dd' }); // Sep 4, 1986 8:30 PM
174✔
925
    locale.dateTimeFormats.LTS = fmt({ h: 'd', m: 'dd', s: 'dd' }); // 8:30:25 PM
174✔
926
    locale.dateTimeFormats.LT = fmt({ h: 'd', m: 'dd' }); // 8:30 PM
174✔
927
    locale.dateTimeFormats.LL = fmt({ Y: 'd', M: 'l', D: 'd' }); // September 4, 1986
174✔
928
    locale.dateTimeFormats.ll = fmt({ Y: 'd', M: 's', D: 'd' }); // Sep 4, 1986
174✔
929
    locale.dateTimeFormats.L = fmt({ Y: 'd', M: 'dd', D: 'dd' }); // 09/04/1986
174✔
930
    locale.dateTimeFormats.l = fmt({ Y: 'd', M: 'd', D: 'd' }); // 9/4/1986
174✔
931
    locale.dateTimeFormats.Z = fmt({ z: 'l', Y: 'd' }); // Don't really want the year, but without *something* else
174✔
932
    locale.dateTimeFormats.z = fmt({ z: 's', Y: 'd' }); //   a whole date appears, and just a year is easier to remove.
174✔
933
    locale.dateTimeFormats.check = fmt({ h: 'd', m: 'd', s: 'd', hourCycle: 'h23' });
174✔
934

935
    Object.keys(locale.dateTimeFormats).forEach(key => {
174✔
936
      if (/^L/i.test(key))
2,262✔
937
        locale.dateTimeFormats['_' + key] = analyzeFormat(locale.name.split(','),
1,740✔
938
          locale.dateTimeFormats[key] as DateTimeFormat);
939
    });
940
  }
941
  else {
942
    locale.dateTimeFormats.LLLL = 'dddd, MMMM D, YYYY at h:mm A'; // Thursday, September 4, 1986 8:30 PM
×
943
    locale.dateTimeFormats.llll = 'ddd, MMM D, YYYY h:mm A'; // Thu, Sep 4, 1986 8:30 PM
×
944
    locale.dateTimeFormats.LLL = 'MMMM D, YYYY at h:mm A'; // September 4, 1986 8:30 PM
×
945
    locale.dateTimeFormats.lll = 'MMM D, YYYY h:mm A'; // Sep 4, 1986 8:30 PM
×
946
    locale.dateTimeFormats.LTS = 'h:mm:ss A'; // 8:30:25 PM
×
947
    locale.dateTimeFormats.LT = 'h:mm A'; // 8:30 PM
×
948
    locale.dateTimeFormats.LL = 'MMMM D, YYYY'; // September 4, 1986
×
949
    locale.dateTimeFormats.ll = 'MMM D, YYYY'; // Sep 4, 1986
×
950
    locale.dateTimeFormats.L = 'MM/DD/YYYY'; // 09/04/1986
×
951
    locale.dateTimeFormats.l = 'M/D/YYYY'; // 9/4/1986
×
952
  }
953
}
954

955
function isLocale(locale: string | string[], matcher: string): boolean {
956
  if (isString(locale))
1,645✔
957
    return locale.startsWith(matcher);
1,642✔
958
  else if (locale.length > 0)
3!
959
    return locale[0].startsWith(matcher);
3✔
960
  else
961
    return false;
×
962
}
963

964
export function analyzeFormat(locale: string | string[], formatter: DateTimeFormat): string;
965
export function analyzeFormat(locale: string | string[], dateStyle: string, timeStyle?: string): string;
966
export function analyzeFormat(locale: string | string[], dateStyleOrFormatter: string | DateTimeFormat,
1✔
967
                              timeStyle?: string): string {
968
  const options: DateTimeFormatOptions = { timeZone: 'UTC', calendar: 'gregory' };
6,427✔
969
  let dateStyle: string;
970

971
  if (dateStyleOrFormatter == null || isString(dateStyleOrFormatter)) {
6,427✔
972
    if (dateStyleOrFormatter)
4,685✔
973
      options.dateStyle = dateStyle = dateStyleOrFormatter as any;
4,216✔
974

975
    if (timeStyle)
4,685✔
976
      options.timeStyle = timeStyle as any;
4,215✔
977
  }
978
  else {
979
    const formatOptions = dateStyleOrFormatter.resolvedOptions();
1,742✔
980

981
    Object.assign(options, formatOptions);
1,742✔
982
    options.timeZone = 'UTC';
1,742✔
983
    dateStyle = (formatOptions as any).dateStyle ??
1,742✔
984
      (options.month === 'long' ? 'long' : options.month === 'short' ? 'short' : null);
2,973✔
985
    timeStyle = (formatOptions as any).timeStyle;
1,742✔
986
  }
987

988
  const sampleDate = Date.UTC(2233, 3 /* 4 */, 5, 6, 7, 8);
6,427✔
989
  const format = newDateTimeFormat(locale, options);
6,427✔
990
  const parts = format.formatToParts(sampleDate);
6,427✔
991
  const dateLong = (dateStyle === 'full' || dateStyle === 'long');
6,427✔
992
  const monthLong = (dateLong || (dateStyle === 'medium' && isLocale(locale, 'ne')));
6,427✔
993
  const timeFull = (timeStyle === 'full');
6,427✔
994
  let formatString = '';
6,427✔
995

996
  parts.forEach(part => {
6,427✔
997
    const value = part.value = convertDigitsToAscii(part.value);
65,914✔
998
    const len = value.length;
65,914✔
999

1000
    switch (part.type) {
65,914✔
1001
      case 'day':
1002
        formatString += 'DD'.substring(0, len);
5,610✔
1003
        break;
5,610✔
1004

1005
      case 'dayPeriod':
1006
        formatString += 'A';
1,805✔
1007
        break;
1,805✔
1008

1009
      case 'hour':
1010
        formatString += ({ h11: 'KK', h12: 'hh', h23: 'HH', h24: 'kk' }
5,263!
1011
          [(format.resolvedOptions() as any).hourCycle ?? 'h23'] ?? 'HH').substr(0, len);
5,263!
1012
        break;
5,263✔
1013

1014
      case 'literal':
1015
        formatString += formatEscape(value);
29,880✔
1016
        break;
29,880✔
1017

1018
      case 'minute':
1019
        formatString += 'mm'.substring(0, len);
5,263✔
1020
        break;
5,263✔
1021

1022
      case 'month':
1023
        if (/^\d+$/.test(value))
5,610✔
1024
          formatString += 'MM'.substring(0, len);
1,732✔
1025
        else
1026
          formatString += (monthLong ? 'MMMM' : 'MMM');
3,878✔
1027
        break;
5,610✔
1028

1029
      case 'second':
1030
        formatString += 'ss'.substring(0, len);
3,345✔
1031
        break;
3,345✔
1032

1033
      case 'timeZoneName':
1034
        formatString += (timeFull ? 'zzz' : 'z');
2,109✔
1035
        break;
2,109✔
1036

1037
      case 'weekday':
1038
        formatString += (dateLong ? 'dddd' : dateStyle === 'medium' ? 'ddd' : 'dd');
1,401!
1039
        break;
1,401✔
1040

1041
      case 'year':
1042
        formatString += (len < 3 ? 'YY' : 'YYYY');
5,610✔
1043
        break;
5,610✔
1044

1045
      case 'era':
1046
        formatString += 'N';
18✔
1047
        break;
18✔
1048
    }
1049
  });
1050

1051
  return formatString;
6,427✔
1052
}
1053

1054
const formatCache: Record<string, DateTimeFormatOptions> = {};
1✔
1055

1056
export function resolveFormatDetails(locale: string | string[], dateStyle: string, timeStyle?: string): DateTimeFormatOptions {
1✔
1057
  const key = JSON.stringify(locale ?? null) + ';' + (dateStyle || '') + ';' + (timeStyle || '');
10,391!
1058
  let result: DateTimeFormatOptions = formatCache[key];
10,391✔
1059

1060
  if (result)
10,391✔
1061
    return result;
7,554✔
1062

1063
  result = {};
2,837✔
1064

1065
  const options: DateTimeFormatOptions = { timeZone: 'UTC', calendar: 'gregory',
2,837✔
1066
                                           dateStyle: dateStyle as any, timeStyle: timeStyle as any };
1067
  const sampleDate = Date.UTC(2233, 3 /* 4 */, 5, 6, 7, 8);
2,837✔
1068
  const format = new DateTimeFormat(locale, options);
2,837✔
1069
  const parts = format.formatToParts(sampleDate);
2,837✔
1070
  const dateLong = (dateStyle === 'full' || dateStyle === 'long');
2,837✔
1071
  const monthLong = (dateLong || (dateStyle === 'medium' && isLocale(locale, 'ne')));
2,837✔
1072

1073
  parts.forEach(part => {
2,837✔
1074
    const value = part.value = convertDigitsToAscii(part.value);
29,412✔
1075
    const len = value.length;
29,412✔
1076
    const asNumber = (len === 2 ? '2-digit' : 'numeric');
29,412✔
1077

1078
    switch (part.type) {
29,412✔
1079
      case 'day':
1080
        result.day = asNumber;
2,367✔
1081
        break;
2,367✔
1082

1083
      case 'dayPeriod':
1084
        result.hour12 = true;
726✔
1085
        break;
726✔
1086

1087
      case 'hour':
1088
        result.hour = asNumber;
2,346✔
1089

1090
        if ((format.resolvedOptions() as any).hourCycle)
2,346!
1091
          result.hourCycle = (format.resolvedOptions() as any).hourCycle;
2,346✔
1092
        else
1093
          result.hourCycle = format.formatToParts(Date.UTC(2233, 3 /* 4 */, 5, 13, 7, 8))['hour'] === '13' ? 'h23' : 'h12';
×
1094

1095
        break;
2,346✔
1096

1097
      case 'minute':
1098
        result.minute = asNumber;
2,346✔
1099
        break;
2,346✔
1100

1101
      case 'month':
1102
        if (/^\d+$/.test(value))
2,367✔
1103
          result.month = asNumber;
752✔
1104
        else
1105
          result.month = (monthLong ? 'long' : 'short');
1,615✔
1106
        break;
2,367✔
1107

1108
      case 'second':
1109
        result.second = asNumber;
1,759✔
1110
        break;
1,759✔
1111

1112
      case 'weekday':
1113
        result.weekday = (dateLong ? 'long' : 'short');
591!
1114
        break;
591✔
1115

1116
      case 'year':
1117
        result.year = (len < 3 ? '2-digit' : 'numeric');
2,367✔
1118
        break;
2,367✔
1119

1120
      case 'era':
1121
        result.era = dateStyle === 'full' ? 'short' : 'long';
10✔
1122
        break;
10✔
1123
    }
1124
  });
1125

1126
  if (result.hourCycle)
2,837✔
1127
    delete result.hour12;
2,346✔
1128

1129
  formatCache[key] = result;
2,837✔
1130

1131
  return result;
2,837✔
1132
}
1133

1134
function validateField(name: string, value: number, min: number, max: number): void {
1135
  if (value < min || value > max)
15,387!
1136
    throw new Error(`${name} value (${value}) out of range [${min}, ${max}]`);
×
1137
}
1138

1139
function matchAmPm(locale: ILocale, input: string): [boolean, number] {
1140
  input = input.toLowerCase().replace(/\xA0/g, ' ');
1,182✔
1141

1142
  for (const meridiem of [locale.meridiemAlt, locale.meridiem, [['am', 'a.m.', 'a. m.'], ['pm', 'p.m.', 'p. m.']]]) {
1,182✔
1143
    if (meridiem == null)
1,375!
1144
      continue;
×
1145

1146
    for (let i = 0; i < meridiem.length; ++i) {
1,375✔
1147
      const forms = meridiem[i];
2,991✔
1148
      const isPM = (i > 11 || (meridiem.length === 2 && i > 0));
2,991✔
1149

1150
      for (const form of forms) {
2,991✔
1151
        if (input.startsWith(form.toLowerCase()))
4,896✔
1152
          return [isPM, form.length];
1,182✔
1153
      }
1154
    }
1155
  }
1156

1157
  return [false, 0];
×
1158
}
1159

1160
function matchEra(locale: ILocale, input: string): [boolean, number] {
1161
  input = input.toLowerCase().replace(/\xA0/g, ' ');
3,453✔
1162

1163
  for (const eras of [locale.eras, ['BC', 'AD', 'BCE', 'CE', 'Before Christ', 'Anno Domini', 'Before Common Era', 'Common Era']]) {
3,453✔
1164
    if (eras == null)
6,902!
1165
      continue;
×
1166

1167
    for (let i = eras.length - 1; i >= 0; --i) {
6,902✔
1168
      const form = eras[i];
41,397✔
1169

1170
      if (input.startsWith(form.toLowerCase()))
41,397✔
1171
        return [i % 2 === 0, form.length];
5✔
1172
    }
1173
  }
1174

1175
  return [false, 0];
3,448✔
1176
}
1177

1178
function matchMonth(locale: ILocale, input: string): [number, number] {
1179
  if (!locale.monthsMin || !locale.monthsShortMin)
2,572!
1180
    return [0, 0];
×
1181

1182
  input = input.toLowerCase().replace(/\u0307/g, '');
2,572✔
1183

1184
  for (const months of [locale.monthsMin, locale.monthsShortMin]) {
2,572✔
1185
    let maxLen = 0;
2,616✔
1186
    let month = 0;
2,616✔
1187

1188
    for (let i = 0; i < 12; ++i) {
2,616✔
1189
      const MMM = convertDigitsToAscii(months[i]);
31,392✔
1190

1191
      if (MMM.length > maxLen && input.startsWith(MMM)) {
31,392✔
1192
        maxLen = MMM.length;
2,588✔
1193
        month = i + 1;
2,588✔
1194
      }
1195
    }
1196

1197
    if (maxLen > 0) {
2,616✔
1198
      while (isLetter(input.charAt(maxLen), true)) ++maxLen;
10,119✔
1199
      return [month, maxLen];
2,572✔
1200
    }
1201
  }
1202

1203
  return [0, 0];
×
1204
}
1205

1206
function skipDayOfWeek(locale: ILocale, input: string): number {
1207
  if (!locale.weekdays || !locale.weekdaysShort || !locale.weekdaysMin)
937!
1208
    return 0;
×
1209

1210
  input = input.toLowerCase();
937✔
1211

1212
  for (const days of [locale.weekdays, locale.weekdaysShort, locale.weekdaysMin]) {
937✔
1213
    let maxLen = 0;
938✔
1214

1215
    for (let i = 0; i < 7; ++i) {
938✔
1216
      const dd = days[i].toLowerCase();
6,566✔
1217

1218
      if (dd.length > maxLen && input.startsWith(dd))
6,566✔
1219
        maxLen = dd.length;
945✔
1220
    }
1221

1222
    if (maxLen > 0) {
938✔
1223
      while (isLetter(input.charAt(maxLen), true)) ++maxLen;
937✔
1224
      return maxLen;
937✔
1225
    }
1226
  }
1227

1228
  return 0;
×
1229
}
1230

1231
function isNumericPart(part: string): boolean {
1232
  return /^[gy]/i.test(part) || (part.length < 3 && /^[WwMDEeHhKkmsS]/.test(part));
29,500✔
1233
}
1234

1235
export function parse(input: string, format: string, zone?: Timezone | string, locales?: string | string[],
1✔
1236
                      allowLeapSecond = false): DateTime {
3,791✔
1237
  let origZone = zone;
3,792✔
1238
  let restoreZone = false;
3,792✔
1239
  let occurrence = 0;
3,792✔
1240

1241
  if (input.includes('₂'))
3,792✔
1242
    occurrence = 2;
1✔
1243

1244
  input = convertDigitsToAscii(input.replace(/[\u00AD\u2010-\u2014\u2212]/g, '-')
3,792✔
1245
    .replace(/\s+/g, ' ').trim()).replace(/[\u200F₂]/g, '');
1246
  format = format.trim().replace(/\u200F/g, '');
3,792✔
1247
  locales = !hasIntlDateTime ? 'en' : normalizeLocale(locales ?? DateTime.getDefaultLocale());
3,792!
1248

1249
  if (isString(zone)) {
3,792✔
1250
    try {
3,763✔
1251
      origZone = zone = Timezone.from(zone);
3,763✔
1252
    }
1253
    catch {}
1254
  }
1255

1256
  const locale = getLocaleInfo(locales);
3,792✔
1257
  let $ = /^(I[FLMSx][FLMS]?)/.exec(format);
3,792✔
1258

1259
  if ($ && $[1] !== 'Ix') {
3,792✔
1260
    const key = $[1];
3,760✔
1261
    const styles = { F: 'full', L: 'long', M: 'medium', S: 'short' };
3,760✔
1262
    format = locale.parsePatterns[key];
3,760✔
1263

1264
    if (!format) {
3,760✔
1265
      format = analyzeFormat(locales, styles[key.charAt(1)], styles[key.charAt(2)]);
1,877✔
1266

1267
      if (!format)
1,877!
1268
        return DateTime.INVALID_DATE;
×
1269

1270
      format = format.replace(/\u200F/g, '');
1,877✔
1271
      locale.parsePatterns[key] = format;
1,877✔
1272
    }
1273
  }
1274
  else if (/^L(L{1,3}|TS?)$/i.test(format))
32✔
1275
    format = (locale.dateTimeFormats['_' + format] ?? locale.dateTimeFormats[format]) as string ?? format;
2!
1276

1277
  const w = {} as DateAndTime;
3,792✔
1278
  const parts = decomposeFormatString(format, true);
3,792✔
1279
  const hasEraField = !!parts.find(part => part.toLowerCase().startsWith('n'));
51,566✔
1280
  const base = DateTime.getDefaultCenturyBase();
3,792✔
1281
  let bce: boolean = null;
3,792✔
1282
  let pm: boolean = null;
3,792✔
1283
  let pos: number;
1284
  let trimmed: boolean;
1285

1286
  for (let i = 0; i < parts.length; ++i) {
3,792✔
1287
    let part = parts[i];
51,766✔
1288
    const nextPart = parts[i + 1];
51,766✔
1289

1290
    if (i % 2 === 0) {
51,766✔
1291
      part = part.trim();
25,987✔
1292
      // noinspection JSNonASCIINames,NonAsciiCharacters
1293
      const altPart = { 'de': 'd’', 'd’': 'de' }[part];
25,987✔
1294

1295
      if (input.startsWith(part))
25,987✔
1296
        input = input.substr(part.length).trimStart();
25,715✔
1297
      else if (altPart && input.startsWith(altPart))
272!
1298
        input = input.substr(altPart.length).trimStart();
×
1299

1300
      // Exact in-between text wasn't matched, but if the next thing coming up is a numeric field,
1301
      // just skip over the text being parsed until the next digit is found.
1302
      if (i < parts.length - 1 && isNumericPart(nextPart)) {
25,987✔
1303
        const $ = /^\D*(?=\d)/.exec(input);
19,179✔
1304

1305
        if ($)
19,179✔
1306
          input = input.substr($[0].length);
19,177✔
1307
        else if (!/^s/i.test(nextPart))
2!
1308
          throw new Error(`Match for "${nextPart}" field not found`);
×
1309
      }
1310

1311
      continue;
25,987✔
1312
    }
1313

1314
    if (part.endsWith('o'))
25,779!
1315
      throw new Error('Parsing of ordinal forms is not supported');
×
1316
    else if (part === 'd')
25,779!
1317
      throw new Error('Parsing "d" token is not supported');
×
1318

1319
    let firstChar = part.substr(0, 1);
25,779✔
1320
    let newValueText = (/^([-+]?\d+)/.exec(input) ?? [])[1];
25,779✔
1321
    let newValue = toNumber(newValueText);
25,779✔
1322
    const value2d = newValue - base % 100 + base + (newValue < base % 100 ? 100 : 0);
25,779✔
1323
    let handled = false;
25,779✔
1324

1325
    if (newValueText != null && part.length < 3 || /[gy]/i.test(part)) {
25,779✔
1326
      handled = true;
19,186✔
1327

1328
      switch (firstChar) {
19,186✔
1329
        case 'Y':
1330
        case 'y':
1331
          if (part.toLowerCase() === 'yy' && newValueText.length < 3)
3,786✔
1332
            w.y = value2d;
459✔
1333
          else if (bce)
3,327!
1334
            w.y = 1 - newValue;
×
1335
          else
1336
            w.y = newValue;
3,327✔
1337

1338
          if (!hasEraField && (parts[i + 2] == null || isNumericPart(parts[i + 2]))) {
3,786✔
1339
            firstChar = 'n';
3,426✔
1340
            handled = false;
3,426✔
1341
            input = input.substr(newValueText?.length ?? 0).trimStart();
3,426!
1342
          }
1343
          break;
3,786✔
1344

1345
        case 'G':
1346
          if (part.length === 2 && newValueText.length < 3)
2!
1347
            w.yw = value2d;
×
1348
          else
1349
            w.yw = newValue;
2✔
1350
          break;
2✔
1351

1352
        case 'g':
1353
          if (part.length === 2 && newValueText.length < 3)
2!
1354
            w.ywl = value2d;
×
1355
          else
1356
            w.ywl = newValue;
2✔
1357
          break;
2✔
1358

1359
        case 'M':
1360
          validateField('month', newValue, 1, 12);
1,214✔
1361
          w.m = newValue;
1,214✔
1362
          break;
1,214✔
1363

1364
        case 'W':
1365
          validateField('week-iso', newValue, 1, 53);
2✔
1366
          w.w = newValue;
2✔
1367
          break;
2✔
1368

1369
        case 'w':
1370
          validateField('week-locale', newValue, 1, 53);
2✔
1371
          w.wl = newValue;
2✔
1372
          break;
2✔
1373

1374
        case 'D':
1375
          validateField('date', newValue, 1, 31);
3,786✔
1376
          w.d = newValue;
3,786✔
1377
          break;
3,786✔
1378

1379
        case 'E':
1380
          validateField('day-of-week-iso', newValue, 1, 7);
2✔
1381
          w.dw = newValue;
2✔
1382
          break;
2✔
1383

1384
        case 'e':
1385
          validateField('day-of-week-locale', newValue, 1, 7);
2✔
1386
          w.dwl = newValue;
2✔
1387
          break;
2✔
1388

1389
        case 'H':
1390
          validateField('hour-24', newValue, 0, 23);
2,602✔
1391
          w.hrs = newValue;
2,602✔
1392
          break;
2,602✔
1393

1394
        case 'h':
1395
          validateField('hour-12', newValue, 1, 12);
1,176✔
1396

1397
          if (pm == null)
1,176✔
1398
            w.hrs = newValue;
1,080✔
1399
          else if (pm)
96!
1400
            w.hrs = newValue === 12 ? 12 : newValue + 12;
×
1401
          else
1402
            w.hrs = newValue === 12 ? 0 : newValue;
96✔
1403
          break;
1,176✔
1404

1405
        case 'm':
1406
          validateField('minute', newValue, 0, 59);
3,778✔
1407
          w.min = newValue;
3,778✔
1408
          break;
3,778✔
1409

1410
        case 's':
1411
          validateField('second', newValue, 0, allowLeapSecond ? 60 : 59);
2,822✔
1412
          w.sec = newValue;
2,822✔
1413
          break;
2,822✔
1414

1415
        case 'S':
1416
          newValueText = newValueText.padEnd(3, '0').substr(0, 3);
1✔
1417
          newValue = toNumber(newValueText);
1✔
1418
          validateField('millisecond', newValue, 0, 999);
1✔
1419
          w.millis = newValue;
1✔
1420
          break;
1✔
1421

1422
        default:
1423
          handled = false;
9✔
1424
      }
1425
    }
1426

1427
    if (handled) {
25,779✔
1428
      input = input.substr(newValueText?.length ?? 0).trimStart();
15,751!
1429
      continue;
15,751✔
1430
    }
1431

1432
    switch (firstChar) {
10,028!
1433
      case 'N':
1434
      case 'n':
1435
        {
1436
          const [isBCE, length] = matchEra(locale, input);
3,453✔
1437

1438
          if (length > 0) {
3,453✔
1439
            bce = isBCE;
5✔
1440
            input = input.substr(length).trimStart();
5✔
1441

1442
            if (w.y != null && bce)
5✔
1443
              w.y = 1 - w.y;
4✔
1444
          }
1445
        }
1446

1447
        handled = true; // Treat as handled no matter what, defaulting to CE.
3,453✔
1448
        break;
3,453✔
1449

1450
      case 'A':
1451
      case 'a':
1452
        {
1453
          const [isPM, length] = matchAmPm(locale, input);
1,182✔
1454

1455
          if (length > 0) {
1,182✔
1456
            handled = true;
1,182✔
1457
            pm = isPM;
1,182✔
1458
            input = input.substr(length).trimStart();
1,182✔
1459

1460
            if (w.hrs != null && pm && w.hrs !== 12)
1,182✔
1461
              w.hrs += 12;
15✔
1462
            else if (w.hrs != null && !pm && w.hrs === 12)
1,167✔
1463
              w.hrs = 0;
528✔
1464
          }
1465
        }
1466
        break;
1,182✔
1467

1468
      case 'M':
1469
        {
1470
          const [month, length] = matchMonth(locale, input);
2,572✔
1471

1472
          if (month > 0) {
2,572✔
1473
            handled = true;
2,572✔
1474
            input = input.substr(length).trimStart();
2,572✔
1475
            w.m = month;
2,572✔
1476
          }
1477
        }
1478
        break;
2,572✔
1479

1480
      case 'd':
1481
        {
1482
          const length = skipDayOfWeek(locale, input);
937✔
1483

1484
          if (length > 0) {
937✔
1485
            handled = true;
937✔
1486
            input = input.substr(length).trimStart();
937✔
1487
          }
1488
        }
1489
        break;
937✔
1490

1491
      case 'Z':
1492
      case 'z':
1493
        trimmed = false;
1,882✔
1494

1495
        if (!/^UTC?[-+]/.test(input) && ($ = /^(Z|\bEtc\/GMT(?:0|[-+]\d{1,2})|[_/a-z]+)([^-+_/a-z]|$)/i.exec(input))) {
1,882✔
1496
          let embeddedZone: string | Timezone = $[1];
1,502✔
1497

1498
          if (/^(Z|UTC?|GMT)$/i.test(embeddedZone))
1,502✔
1499
            embeddedZone = 'UT';
993✔
1500

1501
          embeddedZone = Timezone.from(embeddedZone);
1,502✔
1502
          restoreZone = origZone && !embeddedZone.error;
1,502✔
1503

1504
          if (embeddedZone instanceof Timezone && embeddedZone.error) {
1,502✔
1505
            const szni = Timezone.getShortZoneNameInfo($[1]);
507✔
1506

1507
            if (szni) {
507✔
1508
              w.utcOffset = szni.utcOffset;
3✔
1509
              embeddedZone = Timezone.from(szni.ianaName);
3✔
1510
              restoreZone = !!origZone;
3✔
1511
            }
1512
            else
1513
              embeddedZone = null;
504✔
1514
          }
1515

1516
          if (embeddedZone) {
1,502✔
1517
            zone = embeddedZone;
998✔
1518
            input = input.substr($[1].length).trimStart();
998✔
1519
            trimmed = true;
998✔
1520
          }
1521
        }
1522
        else if (($ = /^(UTC?|GMT)?([-+]\d\d(?:\d{4}|:\d\d(:\d\d)?)?)/i.exec(input))) {
380✔
1523
          w.utcOffset = parseTimeOffset($[2]);
3✔
1524
          input = input.substr($[0].length).trimStart();
3✔
1525
          trimmed = true;
3✔
1526
        }
1527

1528
        // Timezone text is very hard to match when it comes before other parts of the time rather than being
1529
        // the very last thing in a time string, especially (as with Vietnamese) when there's no clear delimiter
1530
        // between the zone name and subsequent text.
1531
        if (!trimmed && locale.name.startsWith('vi')) {
1,882✔
1532
          if ((pos = input.toLowerCase().indexOf('tế')) >= 0) {
8!
1533
            input = input.substr(pos + 2).trimStart();
8✔
1534
            trimmed = true;
8✔
1535
          }
1536
          else if ((pos = (/\s(chủ|thứ)\s/.exec(input.toLowerCase()) ?? { index: -1 }).index) >= 0)  {
×
1537
            input = input.substr(pos + 1);
×
1538
            trimmed = true;
×
1539
          }
1540
        }
1541
        else if (locale.name.startsWith('zh')) {
1,874✔
1542
          if ((pos = input.toLowerCase().indexOf(' ')) >= 0) {
48✔
1543
            input = input.substr(pos).trimStart();
8✔
1544
            trimmed = true;
8✔
1545
          }
1546
        }
1547

1548
        if (!trimmed && nextPart?.trim()) {
1,882✔
1549
          pos = input.toLowerCase().indexOf(nextPart);
88✔
1550

1551
          if (pos >= 0)
88!
1552
            input = input.substr(pos).trimStart();
88✔
1553
          else
1554
            input = input.replace(/^[^,]+/, '');
×
1555
        }
1556

1557
        handled = true;
1,882✔
1558
        break;
1,882✔
1559
    }
1560

1561
    if (!handled) {
10,028✔
1562
      if (firstChar === 's')
2✔
1563
        w.sec = 0;
1✔
1564
      else if (firstChar === 'S')
1!
1565
        w.millis = 0;
1✔
1566
      else
1567
        throw new Error(`Match for "${part}" field not found`);
×
1568
    }
1569
  }
1570

1571
  if (w.y == null && w.yw == null && w.ywl == null)
3,792✔
1572
    zone = undefined;
2✔
1573

1574
  if (occurrence)
3,792✔
1575
    w.occurrence = occurrence;
1✔
1576

1577
  let result = new DateTime(w, zone, locales);
3,792✔
1578

1579
  if (restoreZone && origZone)
3,792✔
1580
    result = result.tz(origZone);
993✔
1581

1582
  return result;
3,792✔
1583
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc