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

kshetline / tubular_time / #49

10 Jul 2025 06:57PM UTC coverage: 95.956% (+2.7%) from 93.285%
#49

push

kshetline
Documentation tweaks.

2113 of 2259 branches covered (93.54%)

Branch coverage included in aggregate %.

3273 of 3354 relevant lines covered (97.58%)

45822.06 hits per line

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

95.64
/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|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,572✔
42

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

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

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

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

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

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

68
    try {
18,577✔
69
      return new DateTimeFormat(locale, options);
18,577✔
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 = '';
30,170✔
81
  let inAlpha = false;
30,170✔
82

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

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

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

104
  return result;
30,170✔
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,583✔
113
  let parts: (string | string[])[] = cache.get(format);
11,583✔
114

115
  if (parts)
11,583✔
116
    return parts as string[];
10,096✔
117
  else
118
    parts = [];
1,487✔
119

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

125
  for (const ch of format.split('')) {
1,487✔
126
    if (/[~a-z]/i.test(ch) || (inBraces && ch === '[')) {
36,836✔
127
      if (inBraces)
23,595✔
128
        literal += ch;
1,270✔
129
      else if (inLiteral) {
22,325✔
130
        parts.push(literal);
9,746✔
131
        literal = '';
9,746✔
132
        token = ch;
9,746✔
133
        inLiteral = false;
9,746✔
134
      }
135
      else
136
        token += ch;
12,579✔
137
    }
138
    else if (ch === '[') {
13,241✔
139
      inBraces = true;
601✔
140

141
      if (!inLiteral) {
601✔
142
        if (stripDateMarks)
43✔
143
          token = token.replace(/~$/, '');
41✔
144

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

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

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

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

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

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

178
    if (part.length === 3 && !part[0] && !part[2])
9,746✔
179
      parts[index] = part[1];
9,712✔
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,487✔
188

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

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

194
  return parts as string[];
1,487✔
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,629✔
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();
12✔
216
}
217

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

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

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

227
  return wt.hrs === getDateValue(fields, 'hour') &&
3,763✔
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,788✔
234
    return '##Invalid_Date##';
1✔
235

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

252
  const dtfMods: DateTimeFormatOptions[] = [];
7,787✔
253

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

260
    return $1;
3,765✔
261
  });
262

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

462
      case 'm': // Numeric minute
463
        result.push(toNum(min));
1✔
464
        break;
1✔
465

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

470
      case 's': // Numeric second
471
        result.push(toNum(sec));
1✔
472
        break;
1✔
473

474
      case 'A': // AM/PM indicator (may have more than just two forms)
475
      case 'a':
476
        {
477
          const values = locale.meridiemAlt ?? /* istanbul ignore next: unreached sanity check */ locale.meridiem;
11✔
478
          const dayPartsForHour = values[values.length === 2 ? floor(hour / 12) : hour];
11✔
479

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

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

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

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

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

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

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

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

527
          /* istanbul ignore next: unreached sanity check */
528
          if (localeFormat == null)
529
            result.push(`[${field}?]`);
530
          /* istanbul ignore next: unreached sanity check */
531
          else if (isString(localeFormat))
532
            result.push(format(dt, localeFormat, localeOverride));
533
          else
534
            result.push(localeFormat.format(dt.epochMillis));
535
        }
536
        break;
17✔
537

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

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

558
      case 'zz':  // As zone acronym (e.g. EST, PDT, AEST), if possible
559
      case 'z':
560
        if (zoneName !== 'TAI' && hasIntlDateTime && locale.dateTimeFormats.z instanceof DateTimeFormat) {
12✔
561
          result.push(getDatePart(locale.dateTimeFormats.z, dt.epochMillis, 'timeZoneName'));
11✔
562
          break;
11✔
563
        }
564
        else /* istanbul ignore next: unreached sanity check */ if (invalidZones.has(zoneName)) {
565
          result.push(dt.timezone.getDisplayName(dt.epochMillis));
566
          break;
567
        }
568
        else if (zoneName !== 'OS') {
569
          result.push(zoneName);
570
          break;
571
        }
572
        /* istanbul ignore else: unreached sanity check */
573
        else
574
          field = 'Z';
575

576
      case 'ZZ': // Zone as UTC offset
577
      case 'Z':
578
        if (zoneName === 'TAI')
3,904✔
579
          result.push(Timezone.formatUtcOffset(dt.wallTime.deltaTai, field === 'ZZ'));
6✔
580
        else
581
          result.push(dt.timezone.getFormattedOffset(dt.epochMillis, field === 'ZZ'));
3,898✔
582
        break;
3,904✔
583

584
      case 'V':
585
      case 'v':
586
        result.push(Timezone.getDstSymbol(wt.dstOffset) + (wt.dstOffset === 0 && field === 'V' ? ' ' : ''));
46✔
587
        break;
46✔
588

589
      case 'R':
590
      case 'r':
591
        result.push(wt.occurrence === 2 ? '\u2082' : field === 'R' ? ' ' : ''); // Subscript 2 (₂)
43✔
592
        break;
43✔
593

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

601
      default:
602
        /* istanbul ignore else: unreached sanity check */
603
        if (field.startsWith('N'))
7,674✔
604
          result.push(locale.eras[(year < 1 ? 0 : 1) + (field.length === 4 ? 2 : 0)]);
4✔
605
        else if (field.startsWith('I')) {
606
          /* istanbul ignore else: unreached backup for lack of Intl library */
607
          if (hasIntlDateTime) {
608
            const formatKey = field + (dtfMods ? JSON.stringify(dtfMods) : /* istanbul ignore next: unreached sanity check */ '');
609
            let intlFormat = locale.dateTimeFormats[formatKey] as DateTimeFormat;
610

611
            if (!intlFormat) {
612
              const options: DateTimeFormatOptions = {};
613
              const dtfMod = dtfMods.splice(0, 1)[0];
614

615
              if (dtfMod)
616
                Object.assign(options, dtfMod);
617

618
              options.calendar = 'gregory';
619

620
              const zone = convertDigitsToAscii(zoneName);
621
              let $: RegExpExecArray;
622

623
              if (zone === 'TAI')
624
                options.timeZone = 'UTC';
625
              else if (($ = /^(?:GMT|UTC?)([-+])(\d\d(?::?\d\d))/.exec(zone))) {
626
                options.timeZone = 'Etc/GMT' + ($[1] === '-' ? '+' : '-') + $[2].replace(/^0+(?=\d)|:|00$/g, '');
627

628
                if (!Timezone.has(options.timeZone))
629
                  delete options.timeZone;
630
              }
631
              else if (zone !== 'OS')
632
                options.timeZone = (zone === 'UT' ? 'UTC' : zone);
633

634
              if (field.charAt(1) !== 'x')
635
                options.dateStyle = styleOptValues[field.charAt(1)];
636

637
              if (field.length > 2)
638
                options.timeStyle = styleOptValues[field.charAt(2)];
639

640
              try {
641
                locale.dateTimeFormats[formatKey] = intlFormat = newDateTimeFormat(localeNames, options);
642
              }
643
              catch {
644
                if (!warnedZones.has(options.timeZone)) {
645
                  console.warn('Timezone "%s" not recognized', options.timeZone);
646
                  warnedZones.add(options.timeZone);
647
                }
648

649
                delete options.timeZone;
650
                locale.dateTimeFormats[formatKey] = intlFormat = newDateTimeFormat(localeNames, options);
651
              }
652
            }
653

654
            if (timeMatch(dt, locale))
655
              result.push(intlFormat.format(dt.epochMillis));
656
            else {
657
              // Favor @tubular/time timezone offsets over those derived from Intl.
658
              let intlFormatAlt = locale.dateTimeFormats['_' + field] as string;
659

660
              if (!intlFormatAlt)
661
                intlFormatAlt = locale.dateTimeFormats['_' + field] = analyzeFormat(currentLocale, intlFormat);
662

663
              result.push(format(dt, intlFormatAlt, localeOverride));
664
            }
665
          }
666
          else {
667
            let intlFormat = '';
668

669
            switch (field.charAt(1)) {
670
              case 'F': intlFormat = 'dddd, MMMM D, YYYY'; break;
671
              case 'L': intlFormat = 'MMMM D, YYYY'; break;
672
              case 'M': intlFormat = 'MMM D, YYYY'; break;
673
              case 'S': intlFormat = 'M/D/YY'; break;
674
            }
675

676
            if (intlFormat && /..[FLMS]/.test(field))
677
              intlFormat += ', ';
678

679
            switch (field.charAt(2)) {
680
              case 'F':
681
              case 'L': intlFormat += 'h:mm:ss A zz'; break;
682
              case 'M': intlFormat += 'h:mm:ss A'; break;
683
              case 'S': intlFormat += 'h:mm A'; break;
684
            }
685

686
            result.push(format(dt, intlFormat));
687
          }
688
        }
689
        else if (field.startsWith('S'))
690
          result.push(toNum(wt.millis.toString().padStart(3, '0').substr(0, field.length), field.length));
691
        else
692
          result.push('??');
693
    }
694

695
    if (dateMark)
35,453✔
696
      result.push(dateMarks[dateMark - 1] + (ko ? '\x80' : ''));
29✔
697
  }
698

699
  let formatted = result.join('');
7,787✔
700

701
  if (usesDateMarks) {
7,787✔
702
    if (cjk)
13✔
703
      dateMarks.forEach(mark => formatted = formatted.replace(new RegExp(mark.repeat(2)), mark));
27✔
704

705
    if (ko || !cjk)
13✔
706
      formatted = formatted.replace(dateMarkCheck, ' ').replace(/\x80/g, '');
6✔
707
  }
708

709
  return formatted;
7,787✔
710
}
711

712
setFormatter(format);
1✔
713

714
function quickFormat(localeNames: string | string[], timezone: string, opts: any): DateTimeFormat {
715
  const options: DateTimeFormatOptions = { calendar: 'gregory' };
10,165✔
716
  let $: RegExpExecArray;
717

718
  localeNames = normalizeLocale(localeNames);
10,165✔
719

720
  if (timezone === 'DATELESS' || timezone === 'ZONELESS' || timezone === 'TAI')
10,165✔
721
    options.timeZone = 'UTC';
104✔
722
  else if (($ = /^(?:GMT|UTC?)([-+])(\d\d(?::?\d\d))/.exec(timezone))) {
10,061✔
723
    options.timeZone = 'Etc/GMT' + ($[1] === '-' ? '+' : '-') + $[2].replace(/^0+(?=\d)|:|00$/g, '');
65✔
724

725
    if (!Timezone.has(options.timeZone))
65✔
726
      delete options.timeZone;
52✔
727
  }
728
  else if (timezone !== 'OS')
9,996✔
729
    options.timeZone = (timezone === 'UT' ? 'UTC' : timezone);
9,983✔
730

731
  Object.keys(opts).forEach(key => {
10,165✔
732
    const value = shortOptValues[opts[key]] ?? opts[key];
16,978✔
733
    key = shortOpts[key] ?? key;
16,978✔
734
    options[key] = value;
16,978✔
735
  });
736

737
  try {
10,165✔
738
    return newDateTimeFormat(localeNames, options);
10,165✔
739
  }
740
  catch (e) {
741
    if (/invalid time zone/i.test(e.message)) {
2✔
742
      const aliases = Timezone.getAliasesForZone(options.timeZone);
2✔
743

744
      aliases.forEach(zone => {
2✔
745
        try {
4✔
746
          options.timeZone = zone;
4✔
747
          return newDateTimeFormat(localeNames, options);
4✔
748
        }
749
        catch {}
750
      });
751
    }
752

753
    throw e;
2✔
754
  }
755
}
756

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

762
  for (let i = 0; i < items.length; ++i) {
246✔
763
    for (let j = 1; j < items[i].length; ++j) {
2,952✔
764
      const item = items[i].substr(0, j);
5,050✔
765
      let matched = false;
5,050✔
766

767
      for (let k = 0; k < items.length && !matched; ++k)
5,050✔
768
        matched = (k !== i && items[k].startsWith(item));
39,505✔
769

770
      if (!matched) {
5,050✔
771
        items[i] = item;
2,355✔
772
        break;
2,355✔
773
      }
774
    }
775
  }
776

777
  return items;
246✔
778
}
779

780
function getLocaleInfo(localeNames: string | string[]): ILocale {
781
  const joinedNames = isArray(localeNames) ? localeNames.join(',') : localeNames;
11,583✔
782
  const locale: ILocale = cachedLocales[joinedNames] ?? {} as ILocale;
11,583✔
783

784
  if (locale && Object.keys(locale).length > 0)
11,583✔
785
    return locale;
11,460✔
786

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

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

791
  /* istanbul ignore else: unreached backup for lack of Intl library */
792
  if (hasIntlDateTime) {
123✔
793
    locale.months = [];
123✔
794
    locale.monthsShort = [];
123✔
795
    const narrow: string[] = [];
123✔
796
    let format: DateTimeFormat;
797
    const fullTimeFormat = new DateTimeFormat(normalizeLocale(locale.name), { timeStyle: 'full', timeZone: 'UTC' });
123✔
798

799
    for (let month = 1; month <= 12; ++month) {
123✔
800
      const date = Date.UTC(2021, month - 1, 1);
1,476✔
801
      let longMonth: string;
802

803
      format = fmt({ ds: 'l' });
1,476✔
804
      longMonth = getDatePart(format, date, 'month');
1,476✔
805

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

809
        if (!allNumeric.test(altForm))
48✔
810
          longMonth = altForm;
48✔
811
      }
812

813
      locale.months.push(longMonth);
1,476✔
814
      format = fmt({ ds: 'm' });
1,476✔
815
      locale.monthsShort.push(getDatePart(format, date, 'month'));
1,476✔
816
      format = fmt({ M: 'n' });
1,476✔
817
      narrow.push(getDatePart(format, date, 'month'));
1,476✔
818
    }
819

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

823
    locale.monthsMin = shortenItems(locale.months);
123✔
824
    locale.monthsShortMin = shortenItems(locale.monthsShort);
123✔
825

826
    locale.weekdays = [];
123✔
827
    locale.weekdaysShort = [];
123✔
828
    locale.weekdaysMin = [];
123✔
829
    locale.meridiemAlt = [];
123✔
830

831
    for (let day = 3; day <= 9; ++day) {
123✔
832
      const date = Date.UTC(2021, 0, day);
861✔
833

834
      format = fmt({ ds: 'f' });
861✔
835
      locale.weekdays.push(getDatePart(format, date, 'weekday'));
861✔
836
      format = fmt({ w: 's' });
861✔
837
      locale.weekdaysShort.push(getDatePart(format, date, 'weekday'));
861✔
838
      format = fmt({ w: 'n' });
861✔
839
      locale.weekdaysMin.push(getDatePart(format, date, 'weekday'));
861✔
840
    }
841

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

846
    const hourForms = new Set<string>();
123✔
847

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

850
    for (let hour = 0; hour < 24; ++hour) {
123✔
851
      const date = Date.UTC(2021, 0, 1, hour,  0, 0);
2,952✔
852
      const value = getDatePart(format, date, 'dayPeriod');
2,952✔
853
      const lcValue = value.toLowerCase();
2,952✔
854
      let newHourForm = value;
2,952✔
855
      const newMeridiems = [];
2,952✔
856

857
      if (value === lcValue)
2,952✔
858
        newMeridiems.push(value);
1,656✔
859
      else
860
        newMeridiems.push(lcValue, value);
1,296✔
861

862
      const fullValue = getDatePart(fullTimeFormat, date, 'dayPeriod');
2,952✔
863
      const lcFullValue = fullValue?.toLowerCase();
2,952✔
864

865
      if (fullValue && fullValue !== value) {
2,952✔
866
        newHourForm += ',' + fullValue;
2,054✔
867

868
        /* istanbul ignore else: unreached sanity check */
869
        if (fullValue === lcFullValue)
2,054✔
870
          newMeridiems.push(fullValue);
2,054✔
871
        else
872
          newMeridiems.push(lcFullValue, fullValue);
873
      }
874

875
      hourForms.add(newHourForm);
2,952✔
876
      locale.meridiemAlt.push(newMeridiems);
2,952✔
877
    }
878

879
    if (hourForms.size < 3) {
123✔
880
      locale.meridiemAlt.splice(13, 11);
122✔
881
      locale.meridiemAlt.splice(1, 11);
122✔
882
    }
883

884
    locale.eras = [getDatePart(fmt({ y: 'n', e: 's' }), Date.UTC(-1, 0, 1), 'era')];
123✔
885
    locale.eras.push(getDatePart(fmt({ y: 'n', e: 's' }), Date.UTC(1, 0, 1), 'era'));
123✔
886
    locale.eras.push(getDatePart(fmt({ y: 'n', e: 'l' }), Date.UTC(-1, 0, 1), 'era'));
123✔
887
    locale.eras.push(getDatePart(fmt({ y: 'n', e: 'l' }), Date.UTC(1, 0, 1), 'era'));
123✔
888

889
    locale.zeroDigit = fmt({ m: 'd' }).format(0);
123✔
890
  }
891
  else {
892
    locale.eras = enEras;
893
    locale.months = enMonths;
894
    locale.monthsMin = shortenItems(locale.months);
895
    locale.monthsShort = enMonthsShort;
896
    locale.monthsShortMin = shortenItems(locale.monthsShort);
897
    locale.weekdays = enWeekdays;
898
    locale.weekdaysShort = enWeekdaysShort;
899
    locale.weekdaysMin = enWeekdaysMin;
900
    locale.zeroDigit = '0';
901
  }
902

903
  locale.dateTimeFormats = {};
123✔
904
  locale.meridiem = getMeridiems(localeNames);
123✔
905
  locale.startOfWeek = getStartOfWeek(localeNames);
123✔
906
  locale.minDaysInWeek = getMinDaysInWeek(localeNames);
123✔
907
  locale.weekend = getWeekend(localeNames);
123✔
908
  locale.ordinals = getOrdinals(localeNames);
123✔
909
  locale.parsePatterns = {};
123✔
910

911
  if (hasPriorityMeridiems(localeNames)) {
123✔
912
    const temp = locale.meridiem;
24✔
913
    locale.meridiem = locale.meridiemAlt;
24✔
914
    locale.meridiemAlt = temp;
24✔
915
  }
916

917
  cachedLocales[joinedNames] = locale;
123✔
918

919
  return locale;
123✔
920
}
921

922
function generatePredefinedFormats(locale: ILocale, timezone: string): void {
923
  const fmt = (opts: any): DateTimeFormat => quickFormat(locale.name, timezone, opts);
2,368✔
924

925
  locale.cachedTimezone = timezone;
184✔
926
  locale.dateTimeFormats = {};
184✔
927

928
  /* istanbul ignore else: unreached backup for lack of Intl library */
929
  if (hasIntlDateTime) {
184✔
930
    locale.dateTimeFormats.LLLL = fmt({ Y: 'd', M: 'l', D: 'd', w: 'l', h: 'd', m: 'dd' }); // Thursday, September 4, 1986 8:30 PM
184✔
931
    locale.dateTimeFormats.llll = fmt({ Y: 'd', M: 's', D: 'd', w: 's', h: 'd', m: 'dd' }); // Thu, Sep 4, 1986 8:30 PM
182✔
932
    locale.dateTimeFormats.LLL = fmt({ Y: 'd', M: 'l', D: 'd', h: 'd', m: 'dd' }); // September 4, 1986 8:30 PM
182✔
933
    locale.dateTimeFormats.lll = fmt({ Y: 'd', M: 's', D: 'd', h: 'd', m: 'dd' }); // Sep 4, 1986 8:30 PM
182✔
934
    locale.dateTimeFormats.LTS = fmt({ h: 'd', m: 'dd', s: 'dd' }); // 8:30:25 PM
182✔
935
    locale.dateTimeFormats.LT = fmt({ h: 'd', m: 'dd' }); // 8:30 PM
182✔
936
    locale.dateTimeFormats.LL = fmt({ Y: 'd', M: 'l', D: 'd' }); // September 4, 1986
182✔
937
    locale.dateTimeFormats.ll = fmt({ Y: 'd', M: 's', D: 'd' }); // Sep 4, 1986
182✔
938
    locale.dateTimeFormats.L = fmt({ Y: 'd', M: 'dd', D: 'dd' }); // 09/04/1986
182✔
939
    locale.dateTimeFormats.l = fmt({ Y: 'd', M: 'd', D: 'd' }); // 9/4/1986
182✔
940
    locale.dateTimeFormats.Z = fmt({ z: 'l', Y: 'd' }); // Don't really want the year, but without *something* else
182✔
941
    locale.dateTimeFormats.z = fmt({ z: 's', Y: 'd' }); //   a whole date appears, and just a year is easier to remove.
182✔
942
    locale.dateTimeFormats.check = fmt({ h: 'd', m: 'd', s: 'd', hourCycle: 'h23' });
182✔
943

944
    Object.keys(locale.dateTimeFormats).forEach(key => {
182✔
945
      if (/^L/i.test(key))
2,366✔
946
        locale.dateTimeFormats['_' + key] = analyzeFormat(locale.name.split(','),
1,820✔
947
          locale.dateTimeFormats[key] as DateTimeFormat);
948
    });
949
  }
950
  else {
951
    locale.dateTimeFormats.LLLL = 'dddd, MMMM D, YYYY at h:mm A'; // Thursday, September 4, 1986 8:30 PM
952
    locale.dateTimeFormats.llll = 'ddd, MMM D, YYYY h:mm A'; // Thu, Sep 4, 1986 8:30 PM
953
    locale.dateTimeFormats.LLL = 'MMMM D, YYYY at h:mm A'; // September 4, 1986 8:30 PM
954
    locale.dateTimeFormats.lll = 'MMM D, YYYY h:mm A'; // Sep 4, 1986 8:30 PM
955
    locale.dateTimeFormats.LTS = 'h:mm:ss A'; // 8:30:25 PM
956
    locale.dateTimeFormats.LT = 'h:mm A'; // 8:30 PM
957
    locale.dateTimeFormats.LL = 'MMMM D, YYYY'; // September 4, 1986
958
    locale.dateTimeFormats.ll = 'MMM D, YYYY'; // Sep 4, 1986
959
    locale.dateTimeFormats.L = 'MM/DD/YYYY'; // 09/04/1986
960
    locale.dateTimeFormats.l = 'M/D/YYYY'; // 9/4/1986
961
  }
962
}
963

964
function isLocale(locale: string | string[], matcher: string): boolean {
965
  /* istanbul ignore else: unreached sanity check */
966
  if (isString(locale))
1,645✔
967
    return locale.startsWith(matcher);
1,642✔
968
  else if (locale.length > 0)
969
    return locale[0].startsWith(matcher);
970
  else
971
    return false;
972
}
973

974
export function analyzeFormat(locale: string | string[], formatter: DateTimeFormat): string;
975
export function analyzeFormat(locale: string | string[], dateStyle: string, timeStyle?: string): string;
976
export function analyzeFormat(locale: string | string[], dateStyleOrFormatter: string | DateTimeFormat,
1✔
977
                              timeStyle?: string): string {
978
  const options: DateTimeFormatOptions = { timeZone: 'UTC', calendar: 'gregory' };
6,508✔
979
  let dateStyle: string;
980

981
  if (dateStyleOrFormatter == null || isString(dateStyleOrFormatter)) {
6,508✔
982
    if (dateStyleOrFormatter)
4,685✔
983
      options.dateStyle = dateStyle = dateStyleOrFormatter as any;
4,216✔
984

985
    if (timeStyle)
4,685✔
986
      options.timeStyle = timeStyle as any;
4,215✔
987
  }
988
  else {
989
    const formatOptions = dateStyleOrFormatter.resolvedOptions();
1,823✔
990

991
    Object.assign(options, formatOptions);
1,823✔
992
    options.timeZone = 'UTC';
1,823✔
993
    dateStyle = (formatOptions as any).dateStyle ??
1,823✔
994
      (options.month === 'long' ? 'long' : options.month === 'short' ? 'short' : null);
3,109✔
995
    timeStyle = (formatOptions as any).timeStyle;
1,823✔
996
  }
997

998
  const sampleDate = Date.UTC(2233, 3 /* 4 */, 5, 6, 7, 8);
6,508✔
999
  const format = newDateTimeFormat(locale, options);
6,508✔
1000
  const parts = format.formatToParts(sampleDate);
6,508✔
1001
  const dateLong = (dateStyle === 'full' || dateStyle === 'long');
6,508✔
1002
  const monthLong = (dateLong || (dateStyle === 'medium' && isLocale(locale, 'ne')));
6,508✔
1003
  const timeFull = (timeStyle === 'full');
6,508✔
1004
  let formatString = '';
6,508✔
1005

1006
  parts.forEach(part => {
6,508✔
1007
    const value = part.value = convertDigitsToAscii(part.value);
66,581✔
1008
    const len = value.length;
66,581✔
1009

1010
    switch (part.type) {
66,581✔
1011
      case 'day':
1012
        formatString += 'DD'.substring(0, len);
5,675✔
1013
        break;
5,675✔
1014

1015
      case 'dayPeriod':
1016
        formatString += 'A';
1,854✔
1017
        break;
1,854✔
1018

1019
      case 'hour':
1020
        formatString += ({ h11: 'KK', h12: 'hh', h23: 'HH', h24: 'kk' }
5,313!
1021
          [(format.resolvedOptions() as any).hourCycle ?? 'h23'] ?? 'HH').substr(0, len);
5,313!
1022
        break;
5,313✔
1023

1024
      case 'literal':
1025
        formatString += formatEscape(value);
30,170✔
1026
        break;
30,170✔
1027

1028
      case 'minute':
1029
        formatString += 'mm'.substring(0, len);
5,313✔
1030
        break;
5,313✔
1031

1032
      case 'month':
1033
        if (/^\d+$/.test(value))
5,675✔
1034
          formatString += 'MM'.substring(0, len);
1,746✔
1035
        else
1036
          formatString += (monthLong ? 'MMMM' : 'MMM');
3,929✔
1037
        break;
5,675✔
1038

1039
      case 'second':
1040
        formatString += 'ss'.substring(0, len);
3,353✔
1041
        break;
3,353✔
1042

1043
      case 'timeZoneName':
1044
        formatString += (timeFull ? 'zzz' : 'z');
2,109✔
1045
        break;
2,109✔
1046

1047
      case 'weekday':
1048
        formatString += (dateLong ? 'dddd' : dateStyle === 'medium' ? 'ddd' : 'dd');
1,417!
1049
        break;
1,417✔
1050

1051
      case 'year':
1052
        formatString += (len < 3 ? 'YY' : 'YYYY');
5,675✔
1053
        break;
5,675✔
1054

1055
      case 'era':
1056
        formatString += 'N';
27✔
1057
        break;
27✔
1058
    }
1059
  });
1060

1061
  return formatString;
6,508✔
1062
}
1063

1064
const formatCache: Record<string, DateTimeFormatOptions> = {};
1✔
1065

1066
export function resolveFormatDetails(locale: string | string[], dateStyle: string, timeStyle?: string): DateTimeFormatOptions {
1✔
1067
  const key = JSON.stringify(locale ?? null) + ';' + (dateStyle || '') + ';' + (timeStyle || '');
10,395!
1068
  let result: DateTimeFormatOptions = formatCache[key];
10,395✔
1069

1070
  if (result)
10,395✔
1071
    return result;
7,558✔
1072

1073
  result = {};
2,837✔
1074

1075
  const options: DateTimeFormatOptions = { timeZone: 'UTC', calendar: 'gregory',
2,837✔
1076
                                           dateStyle: dateStyle as any, timeStyle: timeStyle as any };
1077
  const sampleDate = Date.UTC(2233, 3 /* 4 */, 5, 6, 7, 8);
2,837✔
1078
  const format = new DateTimeFormat(locale, options);
2,837✔
1079
  const parts = format.formatToParts(sampleDate);
2,837✔
1080
  const dateLong = (dateStyle === 'full' || dateStyle === 'long');
2,837✔
1081
  const monthLong = (dateLong || (dateStyle === 'medium' && isLocale(locale, 'ne')));
2,837✔
1082

1083
  parts.forEach(part => {
2,837✔
1084
    const value = part.value = convertDigitsToAscii(part.value);
29,419✔
1085
    const len = value.length;
29,419✔
1086
    const asNumber = (len === 2 ? '2-digit' : 'numeric');
29,419✔
1087

1088
    switch (part.type) {
29,419✔
1089
      case 'day':
1090
        result.day = asNumber;
2,367✔
1091
        break;
2,367✔
1092

1093
      case 'dayPeriod':
1094
        result.hour12 = true;
726✔
1095
        break;
726✔
1096

1097
      case 'hour':
1098
        result.hour = asNumber;
2,346✔
1099

1100
        if ((format.resolvedOptions() as any).hourCycle)
2,346!
1101
          result.hourCycle = (format.resolvedOptions() as any).hourCycle;
2,346✔
1102
        else
1103
          result.hourCycle = format.formatToParts(Date.UTC(2233, 3 /* 4 */, 5, 13, 7, 8))['hour'] === '13' ? 'h23' : 'h12';
×
1104

1105
        break;
2,346✔
1106

1107
      case 'minute':
1108
        result.minute = asNumber;
2,346✔
1109
        break;
2,346✔
1110

1111
      case 'month':
1112
        if (/^\d+$/.test(value))
2,367✔
1113
          result.month = asNumber;
752✔
1114
        else
1115
          result.month = (monthLong ? 'long' : 'short');
1,615✔
1116
        break;
2,367✔
1117

1118
      case 'second':
1119
        result.second = asNumber;
1,759✔
1120
        break;
1,759✔
1121

1122
      case 'weekday':
1123
        result.weekday = (dateLong ? 'long' : 'short');
591!
1124
        break;
591✔
1125

1126
      case 'year':
1127
        result.year = (len < 3 ? '2-digit' : 'numeric');
2,367✔
1128
        break;
2,367✔
1129

1130
      case 'era':
1131
        result.era = dateStyle === 'full' ? 'short' : 'long';
15✔
1132
        break;
15✔
1133
    }
1134
  });
1135

1136
  if (result.hourCycle)
2,837✔
1137
    delete result.hour12;
2,346✔
1138

1139
  formatCache[key] = result;
2,837✔
1140

1141
  return result;
2,837✔
1142
}
1143

1144
function validateField(name: string, value: number, min: number, max: number): void {
1145
  if (value < min || value > max)
15,396!
1146
    throw new Error(`${name} value (${value}) out of range [${min}, ${max}]`);
×
1147
}
1148

1149
function matchAmPm(locale: ILocale, input: string): [boolean, number] {
1150
  input = input.toLowerCase().replace(/\xA0/g, ' ');
1,184✔
1151

1152
  for (const meridiem of [locale.meridiemAlt, locale.meridiem, [['am', 'a.m.', 'a. m.'], ['pm', 'p.m.', 'p. m.']]]) {
1,184✔
1153
    if (meridiem == null)
1,377!
1154
      continue;
×
1155

1156
    for (let i = 0; i < meridiem.length; ++i) {
1,377✔
1157
      const forms = meridiem[i];
2,994✔
1158
      const isPM = (i > 11 || (meridiem.length === 2 && i > 0));
2,994✔
1159

1160
      for (const form of forms) {
2,994✔
1161
        if (input.startsWith(form.toLowerCase()))
4,900✔
1162
          return [isPM, form.length];
1,184✔
1163
      }
1164
    }
1165
  }
1166

1167
  return [false, 0];
×
1168
}
1169

1170
function matchEra(locale: ILocale, input: string): [boolean, number] {
1171
  input = input.toLowerCase().replace(/\xA0/g, ' ');
3,455✔
1172

1173
  for (const eras of [locale.eras, ['BC', 'AD', 'BCE', 'CE', 'Before Christ', 'Anno Domini', 'Before Common Era', 'Common Era']]) {
3,455✔
1174
    if (eras == null)
6,906!
1175
      continue;
×
1176

1177
    for (let i = eras.length - 1; i >= 0; --i) {
6,906✔
1178
      const form = eras[i];
41,421✔
1179

1180
      if (input.startsWith(form.toLowerCase()))
41,421✔
1181
        return [i % 2 === 0, form.length];
5✔
1182
    }
1183
  }
1184

1185
  return [false, 0];
3,450✔
1186
}
1187

1188
function matchMonth(locale: ILocale, input: string): [number, number] {
1189
  if (!locale.monthsMin || !locale.monthsShortMin)
2,573!
1190
    return [0, 0];
×
1191

1192
  input = input.toLowerCase().replace(/\u0307/g, '');
2,573✔
1193

1194
  for (const months of [locale.monthsMin, locale.monthsShortMin]) {
2,573✔
1195
    let maxLen = 0;
2,617✔
1196
    let month = 0;
2,617✔
1197

1198
    for (let i = 0; i < 12; ++i) {
2,617✔
1199
      const MMM = convertDigitsToAscii(months[i]);
31,404✔
1200

1201
      if (MMM.length > maxLen && input.startsWith(MMM)) {
31,404✔
1202
        maxLen = MMM.length;
2,589✔
1203
        month = i + 1;
2,589✔
1204
      }
1205
    }
1206

1207
    if (maxLen > 0) {
2,617✔
1208
      while (isLetter(input.charAt(maxLen), true)) ++maxLen;
10,119✔
1209
      return [month, maxLen];
2,573✔
1210
    }
1211
  }
1212

1213
  return [0, 0];
×
1214
}
1215

1216
function skipDayOfWeek(locale: ILocale, input: string): number {
1217
  if (!locale.weekdays || !locale.weekdaysShort || !locale.weekdaysMin)
937!
1218
    return 0;
×
1219

1220
  input = input.toLowerCase();
937✔
1221

1222
  for (const days of [locale.weekdays, locale.weekdaysShort, locale.weekdaysMin]) {
937✔
1223
    let maxLen = 0;
938✔
1224

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

1228
      if (dd.length > maxLen && input.startsWith(dd))
6,566✔
1229
        maxLen = dd.length;
945✔
1230
    }
1231

1232
    if (maxLen > 0) {
938✔
1233
      while (isLetter(input.charAt(maxLen), true)) ++maxLen;
937✔
1234
      return maxLen;
937✔
1235
    }
1236
  }
1237

1238
  return 0;
×
1239
}
1240

1241
function isNumericPart(part: string): boolean {
1242
  return /^[gy]/i.test(part) || (part.length < 3 && /^[WwMDEeHhKkmsS]/.test(part));
29,519✔
1243
}
1244

1245
export function parse(input: string, format: string, zone?: Timezone | string, locales?: string | string[],
1✔
1246
                      allowLeapSecond = false): DateTime {
3,795✔
1247
  let origZone = zone;
3,796✔
1248
  let restoreZone = false;
3,796✔
1249
  let occurrence = 0;
3,796✔
1250

1251
  if (input.includes('₂'))
3,796✔
1252
    occurrence = 2;
1✔
1253

1254
  input = convertDigitsToAscii(input.replace(/[\u00AD\u2010-\u2014\u2212]/g, '-')
3,796✔
1255
    .replace(/\s+/g, ' ').trim()).replace(/[\u200F₂]/g, '');
1256
  format = format.trim().replace(/\u200F/g, '');
3,796✔
1257
  locales = !hasIntlDateTime ? 'en' : normalizeLocale(locales ?? DateTime.getDefaultLocale());
3,796!
1258

1259
  if (isString(zone)) {
3,796✔
1260
    try {
3,766✔
1261
      origZone = zone = Timezone.from(zone);
3,766✔
1262
    }
1263
    catch {}
1264
  }
1265

1266
  const locale = getLocaleInfo(locales);
3,796✔
1267
  let $ = /^(I[FLMSx][FLMS]?)/.exec(format);
3,796✔
1268

1269
  if ($ && $[1] !== 'Ix') {
3,796✔
1270
    const key = $[1];
3,760✔
1271
    const styles = { F: 'full', L: 'long', M: 'medium', S: 'short' };
3,760✔
1272
    format = locale.parsePatterns[key];
3,760✔
1273

1274
    if (!format) {
3,760✔
1275
      format = analyzeFormat(locales, styles[key.charAt(1)], styles[key.charAt(2)]);
1,877✔
1276

1277
      if (!format)
1,877!
1278
        return DateTime.INVALID_DATE;
×
1279

1280
      format = format.replace(/\u200F/g, '');
1,877✔
1281
      locale.parsePatterns[key] = format;
1,877✔
1282
    }
1283
  }
1284
  else if (/^L(L{1,3}|TS?)$/i.test(format))
36✔
1285
    format = (locale.dateTimeFormats['_' + format] ?? locale.dateTimeFormats[format]) as string ?? format;
2!
1286

1287
  const w = {} as DateAndTime;
3,796✔
1288
  const parts = decomposeFormatString(format, true);
3,796✔
1289
  const hasEraField = !!parts.find(part => part.toLowerCase().startsWith('n'));
51,558✔
1290
  const base = DateTime.getDefaultCenturyBase();
3,796✔
1291
  let bce: boolean = null;
3,796✔
1292
  let pm: boolean = null;
3,796✔
1293
  let pos: number;
1294
  let trimmed: boolean;
1295

1296
  for (let i = 0; i < parts.length; ++i) {
3,796✔
1297
    let part = parts[i];
51,818✔
1298
    const nextPart = parts[i + 1];
51,818✔
1299

1300
    if (i % 2 === 0) {
51,818✔
1301
      part = part.trim();
26,013✔
1302
      // noinspection JSNonASCIINames,NonAsciiCharacters
1303
      const altPart = { 'de': 'd’', 'd’': 'de' }[part];
26,013✔
1304

1305
      if (input.startsWith(part))
26,013✔
1306
        input = input.substr(part.length).trimStart();
25,741✔
1307
      else if (altPart && input.startsWith(altPart))
272!
1308
        input = input.substr(altPart.length).trimStart();
×
1309

1310
      // Exact in-between text wasn't matched, but if the next thing coming up is a numeric field,
1311
      // just skip over the text being parsed until the next digit is found.
1312
      if (i < parts.length - 1 && isNumericPart(nextPart)) {
26,013✔
1313
        const $ = /^\D*(?=\d)/.exec(input);
19,191✔
1314

1315
        if ($)
19,191✔
1316
          input = input.substr($[0].length);
19,189✔
1317
        else if (!/^s/i.test(nextPart))
2!
1318
          throw new Error(`Match for "${nextPart}" field not found`);
×
1319
      }
1320

1321
      continue;
26,013✔
1322
    }
1323

1324
    if (part.endsWith('o'))
25,805✔
1325
      throw new Error('Parsing of ordinal forms is not supported');
1✔
1326
    else if (part === 'd')
25,804✔
1327
      throw new Error('Parsing "d" token is not supported');
1✔
1328

1329
    let firstChar = part.substr(0, 1);
25,803✔
1330
    let newValueText = (/^([-+]?\d+)/.exec(input) ?? [])[1];
25,803✔
1331
    let newValue = toNumber(newValueText);
25,803✔
1332
    const value2d = newValue - base % 100 + base + (newValue < base % 100 ? 100 : 0);
25,803✔
1333
    let handled = false;
25,803✔
1334

1335
    if (newValueText != null && part.length < 3 || /[gy]/i.test(part)) {
25,803✔
1336
      handled = true;
19,198✔
1337

1338
      switch (firstChar) {
19,198✔
1339
        case 'Y':
1340
        case 'y':
1341
          if (part.toLowerCase() === 'yy' && newValueText.length < 3)
3,788✔
1342
            w.y = value2d;
467✔
1343
          else if (bce)
3,321!
1344
            w.y = 1 - newValue;
×
1345
          else
1346
            w.y = newValue;
3,321✔
1347

1348
          if (!hasEraField && (parts[i + 2] == null || isNumericPart(parts[i + 2]))) {
3,788✔
1349
            firstChar = 'n';
3,419✔
1350
            handled = false;
3,419✔
1351
            input = input.substr(newValueText?.length ?? 0).trimStart();
3,419!
1352
          }
1353
          break;
3,788✔
1354

1355
        case 'G':
1356
          if (part.length === 2 && newValueText.length < 3)
2!
1357
            w.yw = value2d;
×
1358
          else
1359
            w.yw = newValue;
2✔
1360
          break;
2✔
1361

1362
        case 'g':
1363
          if (part.length === 2 && newValueText.length < 3)
2!
1364
            w.ywl = value2d;
×
1365
          else
1366
            w.ywl = newValue;
2✔
1367
          break;
2✔
1368

1369
        case 'M':
1370
          validateField('month', newValue, 1, 12);
1,217✔
1371
          w.m = newValue;
1,217✔
1372
          break;
1,217✔
1373

1374
        case 'W':
1375
          validateField('week-iso', newValue, 1, 53);
2✔
1376
          w.w = newValue;
2✔
1377
          break;
2✔
1378

1379
        case 'w':
1380
          validateField('week-locale', newValue, 1, 53);
2✔
1381
          w.wl = newValue;
2✔
1382
          break;
2✔
1383

1384
        case 'D':
1385
          validateField('date', newValue, 1, 31);
3,788✔
1386
          w.d = newValue;
3,788✔
1387
          break;
3,788✔
1388

1389
        case 'E':
1390
          validateField('day-of-week-iso', newValue, 1, 7);
2✔
1391
          w.dw = newValue;
2✔
1392
          break;
2✔
1393

1394
        case 'e':
1395
          validateField('day-of-week-locale', newValue, 1, 7);
2✔
1396
          w.dwl = newValue;
2✔
1397
          break;
2✔
1398

1399
        case 'H':
1400
          validateField('hour-24', newValue, 0, 23);
2,602✔
1401
          w.hrs = newValue;
2,602✔
1402
          break;
2,602✔
1403

1404
        case 'h':
1405
          validateField('hour-12', newValue, 1, 12);
1,178✔
1406

1407
          if (pm == null)
1,178✔
1408
            w.hrs = newValue;
1,082✔
1409
          else if (pm)
96!
1410
            w.hrs = newValue === 12 ? 12 : newValue + 12;
×
1411
          else
1412
            w.hrs = newValue === 12 ? 0 : newValue;
96✔
1413
          break;
1,178✔
1414

1415
        case 'm':
1416
          validateField('minute', newValue, 0, 59);
3,780✔
1417
          w.min = newValue;
3,780✔
1418
          break;
3,780✔
1419

1420
        case 's':
1421
          validateField('second', newValue, 0, allowLeapSecond ? 60 : 59);
2,822✔
1422
          w.sec = newValue;
2,822✔
1423
          break;
2,822✔
1424

1425
        case 'S':
1426
          newValueText = newValueText.padEnd(3, '0').substr(0, 3);
1✔
1427
          newValue = toNumber(newValueText);
1✔
1428
          validateField('millisecond', newValue, 0, 999);
1✔
1429
          w.millis = newValue;
1✔
1430
          break;
1✔
1431

1432
        default:
1433
          handled = false;
10✔
1434
      }
1435
    }
1436

1437
    if (handled) {
25,803✔
1438
      input = input.substr(newValueText?.length ?? 0).trimStart();
15,769!
1439
      continue;
15,769✔
1440
    }
1441

1442
    switch (firstChar) {
10,034✔
1443
      case 'N':
1444
      case 'n':
1445
        {
1446
          const [isBCE, length] = matchEra(locale, input);
3,455✔
1447

1448
          if (length > 0) {
3,455✔
1449
            bce = isBCE;
5✔
1450
            input = input.substr(length).trimStart();
5✔
1451

1452
            if (w.y != null && bce)
5✔
1453
              w.y = 1 - w.y;
4✔
1454
          }
1455
        }
1456

1457
        handled = true; // Treat as handled no matter what, defaulting to CE.
3,455✔
1458
        break;
3,455✔
1459

1460
      case 'A':
1461
      case 'a':
1462
        {
1463
          const [isPM, length] = matchAmPm(locale, input);
1,184✔
1464

1465
          if (length > 0) {
1,184✔
1466
            handled = true;
1,184✔
1467
            pm = isPM;
1,184✔
1468
            input = input.substr(length).trimStart();
1,184✔
1469

1470
            if (w.hrs != null && pm && w.hrs !== 12)
1,184✔
1471
              w.hrs += 12;
16✔
1472
            else if (w.hrs != null && !pm && w.hrs === 12)
1,168✔
1473
              w.hrs = 0;
528✔
1474
          }
1475
        }
1476
        break;
1,184✔
1477

1478
      case 'M':
1479
        {
1480
          const [month, length] = matchMonth(locale, input);
2,573✔
1481

1482
          if (month > 0) {
2,573✔
1483
            handled = true;
2,573✔
1484
            input = input.substr(length).trimStart();
2,573✔
1485
            w.m = month;
2,573✔
1486
          }
1487
        }
1488
        break;
2,573✔
1489

1490
      case 'd':
1491
        {
1492
          const length = skipDayOfWeek(locale, input);
937✔
1493

1494
          if (length > 0) {
937✔
1495
            handled = true;
937✔
1496
            input = input.substr(length).trimStart();
937✔
1497
          }
1498
        }
1499
        break;
937✔
1500

1501
      case 'Z':
1502
      case 'z':
1503
        trimmed = false;
1,883✔
1504

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

1508
          if (/^(Z|UTC?|GMT)$/i.test(embeddedZone))
1,503✔
1509
            embeddedZone = 'UT';
993✔
1510

1511
          embeddedZone = Timezone.from(embeddedZone);
1,503✔
1512
          restoreZone = origZone && !embeddedZone.error;
1,503✔
1513

1514
          if (embeddedZone instanceof Timezone && embeddedZone.error) {
1,503✔
1515
            const szni = Timezone.getShortZoneNameInfo($[1]);
507✔
1516

1517
            if (szni) {
507✔
1518
              w.utcOffset = szni.utcOffset;
3✔
1519
              embeddedZone = Timezone.from(szni.ianaName);
3✔
1520
              restoreZone = !!origZone;
3✔
1521
            }
1522
            else
1523
              embeddedZone = null;
504✔
1524
          }
1525

1526
          if (embeddedZone) {
1,503✔
1527
            zone = embeddedZone;
999✔
1528
            input = input.substr($[1].length).trimStart();
999✔
1529
            trimmed = true;
999✔
1530
          }
1531
        }
1532
        else if (($ = /^(UTC?|GMT)?([-+]\d\d(?:\d{4}|:\d\d(:\d\d)?)?)/i.exec(input))) {
380✔
1533
          w.utcOffset = parseTimeOffset($[2]);
3✔
1534
          input = input.substr($[0].length).trimStart();
3✔
1535
          trimmed = true;
3✔
1536
        }
1537

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

1558
        if (!trimmed && nextPart?.trim()) {
1,883✔
1559
          pos = input.toLowerCase().indexOf(nextPart);
88✔
1560

1561
          if (pos >= 0)
88!
1562
            input = input.substr(pos).trimStart();
88✔
1563
          else
1564
            input = input.replace(/^[^,]+/, '');
×
1565
        }
1566

1567
        handled = true;
1,883✔
1568
        break;
1,883✔
1569
    }
1570

1571
    if (!handled) {
10,034✔
1572
      if (firstChar === 's')
2✔
1573
        w.sec = 0;
1✔
1574
      else if (firstChar === 'S')
1!
1575
        w.millis = 0;
1✔
1576
      else
1577
        throw new Error(`Match for "${part}" field not found`);
×
1578
    }
1579
  }
1580

1581
  if (w.y == null && w.yw == null && w.ywl == null)
3,794✔
1582
    zone = undefined;
2✔
1583

1584
  if (occurrence)
3,794✔
1585
    w.occurrence = occurrence;
1✔
1586

1587
  let result = new DateTime(w, zone, locales);
3,794✔
1588

1589
  if (restoreZone && origZone)
3,794✔
1590
    result = result.tz(origZone);
993✔
1591

1592
  return result;
3,794✔
1593
}
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