DateTimeFormatter::format_era()   A
last analyzed

Complexity

Conditions 3
Paths 3

Size

Total Lines 13
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 8
dl 0
loc 13
rs 10
c 0
b 0
f 0
cc 3
nc 3
nop 2
1
<?php
2
3
namespace ICanBoogie\CLDR\Dates;
4
5
use Closure;
6
use DateTimeImmutable;
7
use DateTimeInterface;
8
use ICanBoogie\CLDR\Core\Formatter;
9
use InvalidArgumentException;
10
11
use function ceil;
12
use function floor;
13
use function is_array;
14
use function is_numeric;
15
use function str_pad;
16
17
use const STR_PAD_LEFT;
18
19
/**
20
 * Provides date and time localization.
21
 *
22
 * The class allows you to format dates and times in a locale-sensitive manner using
23
 * {@link https://www.unicode.org/reports/tr35/tr35-72/tr35-dates.html#Date_Format_Patterns Unicode format patterns}.
24
 */
25
class DateTimeFormatter implements Formatter
26
{
27
    /**
28
     * Pad a numeric value with zero on its left.
29
     */
30
    private static function numeric_pad(int $value, int $length = 2): string
31
    {
32
        return str_pad((string)$value, $length, '0', STR_PAD_LEFT);
33
    }
34
35
    /**
36
     * @param Calendar $calendar
37
     *     The calendar used by the formatter.
38
     */
39
    public function __construct(
40
        public readonly Calendar $calendar
41
    ) {
42
    }
43
44
    /**
45
     * Formats a date according to a pattern.
46
     *
47
     * @param float|int|string|DateTimeInterface $datetime
48
     *     The datetime to format.
49
     *
50
     * @return string
51
     *     The formatted date.
52
     *
53
     * @throws \Exception
54
     *
55
     * @link https://www.unicode.org/reports/tr35/tr35-72/tr35-dates.html#26-element-datetimeformats
56
     */
57
    public function format(
58
        float|int|string|DateTimeInterface $datetime,
59
        string|DateTimeFormatLength|DateTimeFormatId $pattern_or_length_or_id
60
    ): string {
61
        $accessor = new DateTimeAccessor($this->ensure_datetime($datetime));
62
        $pattern = $this->resolve_pattern($pattern_or_length_or_id);
63
        $tokens = DateFormatPatternParser::parse($pattern);
64
65
        $rc = '';
66
67
        foreach ($tokens as $token) {
68
            if (is_array($token)) {
69
                [ $c, $l ] = $token;
70
71
                $f = $this->match_formatter($c, $pattern);
72
                $token = $f($accessor, $l);
73
            }
74
75
            $rc .= $token;
76
        }
77
78
        return $rc;
79
    }
80
81
    /**
82
     * @param string $c
83
     *      A pattern character.
84
     *
85
     * @return Closure(DateTimeAccessor, int $length):string
86
     */
87
    private function match_formatter(string $c, string $pattern): Closure
88
    {
89
        return match ($c) {
90
            'G' => $this->format_era(...),
91
            'y' => $this->format_year(...),
92
//            'Y' => Year (in "Week of Year" based calendars)
93
//            'u' => Extended year
94
            'Q' => $this->format_quarter(...),
95
            'q' => $this->format_standalone_quarter(...),
96
            'M' => $this->format_month(...),
97
            'L' => $this->format_standalone_month(...),
98
//            'l' => Special symbol for Chinese leap month, used in combination with M.
99
            'w' => $this->format_week_of_year(...),
100
            'W' => $this->format_week_of_month(...),
101
            'd' => $this->format_day_of_month(...),
102
            'D' => $this->format_day_of_year(...),
103
            'F' => $this->format_day_of_week_in_month(...),
104
            'h' => $this->format_hour12(...),
105
            'H' => $this->format_hour24(...),
106
            'm' => $this->format_minutes(...),
107
            's' => $this->format_seconds(...),
108
            'E' => $this->format_day_in_week(...),
109
            'c' => $this->format_day_in_week_stand_alone(...),
110
            'e' => $this->format_day_in_week_local(...),
111
            'a' => $this->format_period(...),
112
            'k' => $this->format_hour_in_day(...),
113
            'K' => $this->format_hour_in_period(...),
114
            'z', 'v' => $this->format_timezone_non_location(...),
115
            'Z' => $this->format_timezone_basic(...),
116
            default => throw new InvalidArgumentException("Unsupported date pattern character '$c' used in '$pattern'")
117
        };
118
    }
119
120
    /**
121
     * Resolves the specified pattern, which can be a width, a skeleton, or an actual pattern.
122
     */
123
    protected function resolve_pattern(
124
        string|DateTimeFormatLength|DateTimeFormatId $pattern_or_length_or_id
125
    ): string {
126
        if ($pattern_or_length_or_id instanceof DateTimeFormatLength) {
127
            $length = $pattern_or_length_or_id->value;
128
            $calendar = $this->calendar;
129
            $datetime_pattern = $calendar['dateTimeFormats-atTime']['standard'][$length]
130
                ?? $calendar['dateTimeFormats'][$length];
131
            $date_pattern = $calendar['dateFormats'][$length];
132
            $time_pattern = $calendar['timeFormats'][$length];
133
134
            return strtr($datetime_pattern, [
135
                '{1}' => $date_pattern,
136
                '{0}' => $time_pattern
137
            ]);
138
        } elseif ($pattern_or_length_or_id instanceof DateTimeFormatId) {
139
            $id = $pattern_or_length_or_id->id;
140
141
            return $this->calendar['dateTimeFormats']['availableFormats'][$id]
142
                ?? throw new InvalidArgumentException("Unknown DateTime format id: $id");
143
        }
144
145
        return $pattern_or_length_or_id;
146
    }
147
148
    /**
149
     * Era (G); Replaced with the Era string for the current date.
150
     *
151
     * One to three letters for the abbreviated form, four letters for the long form, five for the narrow form: [1..3,
152
     * 4, 5]
153
     *
154
     * @TODO How to support multiple Eras?, e.g. Japanese.
155
     */
156
    private function format_era(DateTimeAccessor $datetime, int $length): string
157
    {
158
        if ($length > 5) {
159
            return '';
160
        }
161
162
        $era = ($datetime->year > 0) ? 1 : 0;
163
164
        return match ($length) {
165
            1, 2, 3 => $this->calendar->abbreviated_eras[$era],
166
            4 => $this->calendar->wide_eras[$era],
167
            5 => $this->calendar->narrow_eras[$era],
168
            default => '',
169
        };
170
    }
171
172
    /**
173
     * Year (y); Normally the length specifies the padding, but for two letters it also specifies the maximum length:
174
     * [1..n].
175
     */
176
    private function format_year(DateTimeAccessor $datetime, int $length): string
177
    {
178
        $year = $datetime->year;
179
180
        if ($length == 2) {
181
            $year = $year % 100;
182
        }
183
184
        return self::numeric_pad($year, $length);
185
    }
186
187
    /**
188
     * Quarter (Q); One or two "Q" for the numerical quarter, three for the abbreviation, or four for the full (wide)
189
     * name: [1..2, 3, 4]
190
     *
191
     * @uses Calendar::$abbreviated_quarters
192
     * @uses Calendar::$wide_quarters
193
     */
194
    private function format_quarter(
195
        DateTimeAccessor $datetime,
196
        int $length,
197
        string $abbreviated = 'abbreviated_quarters',
198
        string $wide = 'wide_quarters'
199
    ): string {
200
        if ($length > 4) {
201
            return '';
202
        }
203
204
        $quarter = $datetime->quarter;
205
206
        return match ($length) {
207
            1 => (string)$quarter,
208
            2 => self::numeric_pad($quarter),
209
            3 => $this->calendar->$abbreviated[$quarter],
210
            4 => $this->calendar->$wide[$quarter],
211
            default => '',
212
        };
213
    }
214
215
    /**
216
     * Stand-Alone Quarter (q); One or two "q" for the numerical quarter, three for the abbreviation, or four for the
217
     * full (wide) name: [1..2, 3, 4]
218
     *
219
     * @uses Calendar::$standalone_abbreviated_quarters
220
     * @uses Calendar::$standalone_wide_quarters
221
     */
222
    private function format_standalone_quarter(DateTimeAccessor $datetime, int $length): string
223
    {
224
        return $this->format_quarter(
225
            datetime: $datetime,
226
            length: $length,
227
            abbreviated: 'standalone_abbreviated_quarters',
228
            wide: 'standalone_wide_quarters',
229
        );
230
    }
231
232
    /*
233
     * Month (M|L)
234
     */
235
236
    /**
237
     * Month (M); One or two "M" for the numerical month, three for the abbreviation, four for the full name, or five
238
     * for the narrow name: [1..2, 3, 4, 5]
239
     */
240
    private function format_month(DateTimeAccessor $datetime, int $length): string
241
    {
242
        return $this->do_format_month(
243
            datetime: $datetime,
244
            length: $length,
245
            abbreviated: $this->calendar->abbreviated_months,
246
            wide: $this->calendar->wide_months,
247
            narrow: $this->calendar->narrow_months,
248
        );
249
    }
250
251
    /**
252
     * Stand-Alone Month (L); One or two "L" for the numerical month, three for the abbreviation, or four for the full
253
     * (wide) name, or 5 for the narrow name: [1..2, 3, 4, 5]
254
     */
255
    private function format_standalone_month(DateTimeAccessor $datetime, int $length): string
256
    {
257
        return $this->do_format_month(
258
            datetime: $datetime,
259
            length: $length,
260
            abbreviated: $this->calendar->standalone_abbreviated_months,
261
            wide: $this->calendar->standalone_wide_months,
262
            narrow: $this->calendar->standalone_narrow_months,
263
        );
264
    }
265
266
    /**
267
     * @param array<int, string> $abbreviated
268
     * @param array<int, string> $wide
269
     * @param array<int, string> $narrow
270
     */
271
    private function do_format_month(
272
        DateTimeAccessor $datetime,
273
        int $length,
274
        array $abbreviated,
275
        array $wide,
276
        array $narrow,
277
    ): string {
278
        if ($length > 5) {
279
            return '';
280
        }
281
282
        /** @var int<1, 5> $length */
283
284
        $month = $datetime->month;
285
286
        return match ($length) {
287
            1 => (string)$month,
288
            2 => self::numeric_pad($month),
289
            3 => $abbreviated[$month],
290
            4 => $wide[$month],
291
            5 => $narrow[$month],
292
        };
293
    }
294
295
    /*
296
     * Week (w|W)
297
     */
298
299
    /**
300
     * Week of the year (w); [1..2].
301
     */
302
    private function format_week_of_year(DateTimeAccessor $datetime, int $length): string
303
    {
304
        if ($length > 2) {
305
            return '';
306
        }
307
308
        $week = $datetime->week;
309
310
        return $length == 1 ? (string)$week : self::numeric_pad($week);
311
    }
312
313
    /**
314
     * Week of the month (W); [1].
315
     */
316
    private function format_week_of_month(DateTimeAccessor $datetime, int $length): string
317
    {
318
        if ($length > 1) {
319
            return '';
320
        }
321
322
        return (string)ceil($datetime->day / 7) ?: "0";
323
    }
324
325
    /*
326
     * Day (d,D,F)
327
     */
328
329
    /**
330
     * Day of the month (d); [1..2].
331
     */
332
    private function format_day_of_month(DateTimeAccessor $datetime, int $length): string
333
    {
334
        if ($length > 2) {
335
            return '';
336
        }
337
338
        $day = $datetime->day;
339
340
        if ($length == 1) {
341
            return (string)$day;
342
        }
343
344
        return self::numeric_pad($day);
345
    }
346
347
    /**
348
     * Day of the year (D); [1..3].
349
     */
350
    private function format_day_of_year(DateTimeAccessor $datetime, int $length): string
351
    {
352
        $day = $datetime->year_day;
353
354
        if ($length > 3) {
355
            return '';
356
        }
357
358
        return self::numeric_pad($day, $length);
359
    }
360
361
    /**
362
     * Day of the week in a month (F); For example, "2nd Wed in July": [1].
363
     */
364
    private function format_day_of_week_in_month(DateTimeAccessor $datetime, int $length): string
365
    {
366
        if ($length > 1) {
367
            return '';
368
        }
369
370
        return (string)floor(($datetime->day + 6) / 7);
371
    }
372
373
    /*
374
     * Weekday (E,e,c)
375
     */
376
377
    /**
378
     * Weekday (E); One through three letters for the short day, or four for the full name, five for the narrow name, or
379
     * six for the short name: [1..3, 4, 5, 6].
380
     */
381
    private function format_day_in_week(DateTimeAccessor $datetime, int $length): string
382
    {
383
        if ($length > 6) {
384
            return '';
385
        }
386
387
        $day = $datetime->weekday;
388
        $code = $this->name_for_day_code($day);
389
        $calendar = $this->calendar;
390
391
        return match ($length) {
392
            1, 2, 3 => $calendar->abbreviated_days[$code],
393
            4 => $calendar->wide_days[$code],
394
            5 => $calendar->narrow_days[$code],
395
            6 => $calendar->short_days[$code],
396
            default => '',
397
        };
398
    }
399
400
    /**
401
     * Stand-Alone weekday (e); One letter for the local numeric value (same as 'e'), three for the abbreviated day
402
     * name, four for the full (wide) name, five for the narrow name, or six for the short name.
403
     */
404
    private function format_day_in_week_stand_alone(DateTimeAccessor $datetime, int $length): string
405
    {
406
        if ($length == 2 || $length > 6) {
407
            return '';
408
        }
409
410
        $day = $datetime->weekday;
411
412
        if ($length == 1) {
413
            return (string)$day;
414
        }
415
416
        /** @var int<3, 6> $length */
417
418
        $days = match ($length) {
419
            3 => $this->calendar->standalone_abbreviated_days,
420
            4 => $this->calendar->standalone_wide_days,
421
            5 => $this->calendar->standalone_narrow_days,
422
            6 => $this->calendar->standalone_short_days,
423
        };
424
425
        $code = $this->name_for_day_code($day);
426
427
        return $days[$code];
428
    }
429
430
    /**
431
     * Local day of the week (c); Same as E except adds a numeric value that will depend on the local starting day of
432
     * the week, using one or two letters; For example, Monday is the first day of the week.
433
     */
434
    private function format_day_in_week_local(DateTimeAccessor $datetime, int $length): string
435
    {
436
        if ($length < 3) {
437
            return (string)$datetime->weekday;
438
        }
439
440
        return $this->format_day_in_week($datetime, $length);
441
    }
442
443
    /**
444
     * Period (a); AM or PM. [1]
445
     *
446
     * @return string AM or PM designator.
447
     */
448
    private function format_period(DateTimeAccessor $datetime): string
449
    {
450
        return $this->calendar['dayPeriods']['format']['abbreviated'][$datetime->hour < 12 ? 'am' : 'pm'];
451
    }
452
453
    /*
454
     * hour (h,H,K,k)
455
     */
456
457
    /**
458
     * Hour [1-12] (h); When used in skeleton data or in a skeleton passed in an API for flexible data pattern
459
     * generation, it should match the 12-hour-cycle format preferred by the locale (h or K); it shouldn't match a
460
     * 24-hour-cycle format (H or k).
461
     *
462
     * Use "hh" for zero-padding; [1..2].
463
     */
464
    private function format_hour12(DateTimeAccessor $datetime, int $length): string
465
    {
466
        if ($length > 2) {
467
            return '';
468
        }
469
470
        $hour = $datetime->hour;
471
        $hour = ($hour == 12) ? 12 : $hour % 12;
472
473
        if ($length == 1) {
474
            return (string)$hour;
475
        }
476
477
        return self::numeric_pad($hour);
478
    }
479
480
    /**
481
     * Hour [0-23] (H); When used in skeleton data or in a skeleton passed in an API for flexible data pattern
482
     * generation, it should match the 24-hour-cycle format preferred by the locale (H or k); it shouldn't match a
483
     * 12-hour-cycle format (h or K).
484
     *
485
     * Use "HH" for zero-padding; [1..2].
486
     */
487
    private function format_hour24(DateTimeAccessor $datetime, int $length): string
488
    {
489
        if ($length > 2) {
490
            return '';
491
        }
492
493
        $hour = $datetime->hour;
494
495
        if ($length == 1) {
496
            return (string)$hour;
497
        }
498
499
        return self::numeric_pad($hour);
500
    }
501
502
    /**
503
     * Hour [0-11] (K); When used in a skeleton, only matches K or h, see above.
504
     *
505
     * Use "KK" for zero-padding; [1..2].
506
     */
507
    private function format_hour_in_period(DateTimeAccessor $datetime, int $length): string
508
    {
509
        if ($length > 2) {
510
            return '';
511
        }
512
513
        $hour = $datetime->hour % 12;
514
515
        if ($length == 1) {
516
            return (string)$hour;
517
        }
518
519
        return self::numeric_pad($hour);
520
    }
521
522
    /**
523
     * Hour [1-24] (k); When used in a skeleton, only matches k or H, see above.
524
     *
525
     * Use "kk" for zero-padding; [1..2].
526
     */
527
    private function format_hour_in_day(DateTimeAccessor $datetime, int $length): string
528
    {
529
        if ($length > 2) {
530
            return '';
531
        }
532
533
        $hour = $datetime->hour ?: 24;
534
535
        if ($length == 1) {
536
            return (string)$hour;
537
        }
538
539
        return self::numeric_pad($hour);
540
    }
541
542
    /**
543
     * Minute (m); One or two "m" for zero-padding.
544
     */
545
    private function format_minutes(DateTimeAccessor $datetime, int $length): string
546
    {
547
        return $this->format_minutes_or_seconds($datetime, $length, 'minute');
548
    }
549
550
    /**
551
     * Second (s); One or two "s" for zero-padding.
552
     */
553
    private function format_seconds(DateTimeAccessor $datetime, int $length): string
554
    {
555
        return $this->format_minutes_or_seconds($datetime, $length, 'second');
556
    }
557
558
    /**
559
     * Minute (m); One or two "m" for zero-padding.
560
     */
561
    private function format_minutes_or_seconds(DateTimeAccessor $datetime, int $length, string $which): string
562
    {
563
        if ($length > 2) {
564
            return '';
565
        }
566
567
        $value = $datetime->$which;
568
569
        if ($length == 1) {
570
            return $value;
571
        }
572
573
        return self::numeric_pad($value);
574
    }
575
576
    /*
577
     * zone (z,Z,v)
578
     */
579
580
    /**
581
     * The ISO8601 basic format (z).
582
     */
583
    private function format_timezone_basic(DateTimeAccessor $datetime): string
584
    {
585
        return $datetime->format('O');
586
    }
587
588
    /**
589
     * The specific non-location format (Z, v).
590
     */
591
    private function format_timezone_non_location(DateTimeAccessor $datetime): string
592
    {
593
        $str = $datetime->format('T');
594
595
        return $str === 'Z' ? 'UTC' : $str;
596
    }
597
598
    /**
599
     * @throws \Exception if the {@see DateTimeImmutable} instance can't be created.
600
     */
601
    private function ensure_datetime(float|int|string|DateTimeInterface $datetime): DateTimeInterface
602
    {
603
        if ($datetime instanceof DateTimeInterface) {
604
            return $datetime;
605
        }
606
607
        return new DateTimeImmutable(is_numeric($datetime) ? "@$datetime" : (string)$datetime);
608
    }
609
610
    /**
611
     * @param int<1, 7> $day
612
     *     A day code.
613
     */
614
    private function name_for_day_code(int $day): string
615
    {
616
        return match ($day) {
617
            1 => 'mon',
618
            2 => 'tue',
619
            3 => 'wed',
620
            4 => 'thu',
621
            5 => 'fri',
622
            6 => 'sat',
623
            7 => 'sun',
624
        };
625
    }
626
}
627