Passed
Push — 6.0 ( 24c103...310980 )
by Olivier
01:40
created

DateTimeFormatter::format_timezone_basic()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

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