Passed
Push — 6.0 ( e6360e...a5ddb0 )
by Olivier
01:38
created

DateTimeFormatter   F

Complexity

Total Complexity 74

Size/Duplication

Total Lines 600
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
eloc 196
dl 0
loc 600
rs 2.48
c 0
b 0
f 0
wmc 74

31 Methods

Rating   Name   Duplication   Size   Complexity  
A format_seconds() 0 3 1
A format_period() 0 3 2
A format_year() 0 9 2
A format_day_in_week_local() 0 7 2
A format_day_of_year() 0 9 2
A format_hour_in_day() 0 13 4
A format_minutes_or_seconds() 0 13 3
A format_hour24() 0 13 3
A ensure_datetime() 0 7 3
A format_standalone_quarter() 0 7 1
A match_formatter() 0 30 1
A format_era() 0 13 3
A format_day_of_week_in_month() 0 7 2
A format_week_of_year() 0 9 3
A format_day_in_week_stand_alone() 0 24 4
A format_timezone_non_location() 0 5 2
A name_for_day_code() 0 10 1
A format_minutes() 0 3 1
B format_month() 0 30 7
A format_week_of_month() 0 7 3
A __construct() 0 3 1
A format_day_in_week() 0 16 2
A resolve_pattern() 0 23 3
A format() 0 22 3
A numeric_pad() 0 3 1
A format_standalone_month() 0 8 1
A format_hour12() 0 14 4
A format_hour_in_period() 0 13 3
A format_timezone_basic() 0 3 1
A format_quarter() 0 18 2
A format_day_of_month() 0 13 3

How to fix   Complexity   

Complex Class

Complex classes like DateTimeFormatter often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use DateTimeFormatter, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
namespace ICanBoogie\CLDR;
4
5
use Closure;
6
use DateTimeImmutable;
7
use DateTimeInterface;
8
use InvalidArgumentException;
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
16
use const STR_PAD_LEFT;
17
18
/**
19
 * Provides date and time localization.
20
 *
21
 * The class allows you to format dates and times in a locale-sensitive manner using
22
 * {@link https://www.unicode.org/reports/tr35/tr35-72/tr35-dates.html#Date_Format_Patterns Unicode format patterns}.
23
 */
24
class DateTimeFormatter implements Formatter
25
{
26
    /**
27
     * Pad a numeric value with zero on its left.
28
     */
29
    private static function numeric_pad(int $value, int $length = 2): string
30
    {
31
        return str_pad((string)$value, $length, '0', STR_PAD_LEFT);
32
    }
33
34
    /**
35
     * @param Calendar $calendar
36
     *     The calendar used by the formatter.
37
     */
38
    public function __construct(
39
        public readonly Calendar $calendar
40
    ) {
41
    }
42
43
    /**
44
     * Formats a date according to a pattern.
45
     *
46
     * @param float|int|string|DateTimeInterface $datetime
47
     *     The datetime to format.
48
     *
49
     * @return string
50
     *     The formatted date.
51
     *
52
     * @throws \Exception
53
     *
54
     * @see https://www.unicode.org/reports/tr35/tr35-72/tr35-dates.html#26-element-datetimeformats
55
     */
56
    public function format(
57
        float|int|string|DateTimeInterface $datetime,
58
        string|DateTimeFormatLength|DateTimeFormatId $pattern_or_length_or_id
59
    ): string {
60
        $accessor = new DateTimeAccessor($this->ensure_datetime($datetime));
61
        $pattern = $this->resolve_pattern($pattern_or_length_or_id);
62
        $tokens = DateFormatPatternParser::parse($pattern);
63
64
        $rc = '';
65
66
        foreach ($tokens as $token) {
67
            if (is_array($token)) {
68
                [ $c, $l ] = $token;
69
70
                $f = $this->match_formatter($c, $pattern);
71
                $token = $f($accessor, $l);
72
            }
73
74
            $rc .= $token;
75
        }
76
77
        return $rc;
78
    }
79
80
    /**
81
     * @param string $c
82
     *      A pattern character.
83
     *
84
     * @return Closure(DateTimeAccessor, int $length):string
85
     */
86
    private function match_formatter(string $c, string $pattern): Closure
87
    {
88
        return match ($c) {
89
            'G' => $this->format_era(...),
90
            'y' => $this->format_year(...),
91
//            'Y' => Year (in "Week of Year" based calendars)
92
//            'u' => Extended year
93
            'Q' => $this->format_quarter(...),
94
            'q' => $this->format_standalone_quarter(...),
95
            'M' => $this->format_month(...),
96
            'L' => $this->format_standalone_month(...),
97
//            'l' => Special symbol for Chinese leap month, used in combination with M.
98
            'w' => $this->format_week_of_year(...),
99
            'W' => $this->format_week_of_month(...),
100
            'd' => $this->format_day_of_month(...),
101
            'D' => $this->format_day_of_year(...),
102
            'F' => $this->format_day_of_week_in_month(...),
103
            'h' => $this->format_hour12(...),
104
            'H' => $this->format_hour24(...),
105
            'm' => $this->format_minutes(...),
106
            's' => $this->format_seconds(...),
107
            'E' => $this->format_day_in_week(...),
108
            'c' => $this->format_day_in_week_stand_alone(...),
109
            'e' => $this->format_day_in_week_local(...),
110
            'a' => $this->format_period(...),
111
            'k' => $this->format_hour_in_day(...),
112
            'K' => $this->format_hour_in_period(...),
113
            'z', 'v' => $this->format_timezone_non_location(...),
114
            'Z' => $this->format_timezone_basic(...),
115
            default => throw new InvalidArgumentException("Unsupported date pattern character '$c' used in '$pattern'")
116
        };
117
    }
118
119
    /**
120
     * Resolves the specified pattern, which can be a width, a skeleton, or an actual pattern.
121
     */
122
    protected function resolve_pattern(
123
        string|DateTimeFormatLength|DateTimeFormatId $pattern_or_length_or_id
124
    ): string {
125
        if ($pattern_or_length_or_id instanceof DateTimeFormatLength) {
126
            $length = $pattern_or_length_or_id->value;
127
            $calendar = $this->calendar;
128
            $datetime_pattern = $calendar['dateTimeFormats-atTime']['standard'][$length]
129
                ?? $calendar['dateTimeFormats'][$length];
130
            $date_pattern = $calendar['dateFormats'][$length];
131
            $time_pattern = $calendar['timeFormats'][$length];
132
133
            return strtr($datetime_pattern, [
134
                '{1}' => $date_pattern,
135
                '{0}' => $time_pattern
136
            ]);
137
        } elseif ($pattern_or_length_or_id instanceof DateTimeFormatId) {
138
            $id = $pattern_or_length_or_id->id;
139
140
            return $this->calendar['dateTimeFormats']['availableFormats'][$id]
141
                ?? throw new InvalidArgumentException("Unknown DateTime format id: $id");
142
        }
143
144
        return $pattern_or_length_or_id;
145
    }
146
147
    /**
148
     * Era (G); Replaced with the Era string for the current date.
149
     *
150
     * One to three letters for the abbreviated form, four letters for the long form, five for the narrow form: [1..3,
151
     * 4, 5]
152
     *
153
     * @TODO How to support multiple Eras?, e.g. Japanese.
154
     */
155
    private function format_era(DateTimeAccessor $datetime, int $length): string
156
    {
157
        if ($length > 5) {
158
            return '';
159
        }
160
161
        $era = ($datetime->year > 0) ? 1 : 0;
162
163
        return match ($length) {
164
            1, 2, 3 => $this->calendar->abbreviated_eras[$era],
165
            4 => $this->calendar->wide_eras[$era],
166
            5 => $this->calendar->narrow_eras[$era],
167
            default => '',
168
        };
169
    }
170
171
    /**
172
     * Year (y); Normally the length specifies the padding, but for two letters it also specifies the maximum length:
173
     * [1..n].
174
     */
175
    private function format_year(DateTimeAccessor $datetime, int $length): string
176
    {
177
        $year = $datetime->year;
178
179
        if ($length == 2) {
180
            $year = $year % 100;
181
        }
182
183
        return self::numeric_pad($year, $length);
184
    }
185
186
    /**
187
     * Quarter (Q); One or two "Q" for the numerical quarter, three for the abbreviation, or four for the full (wide)
188
     * name: [1..2, 3, 4]
189
     *
190
     * @uses Calendar::$abbreviated_quarters
191
     * @uses Calendar::$wide_quarters
192
     */
193
    private function format_quarter(
194
        DateTimeAccessor $datetime,
195
        int $length,
196
        string $abbreviated = 'abbreviated_quarters',
197
        string $wide = 'wide_quarters'
198
    ): string {
199
        if ($length > 4) {
200
            return '';
201
        }
202
203
        $quarter = $datetime->quarter;
204
205
        return match ($length) {
206
            1 => (string)$quarter,
207
            2 => self::numeric_pad($quarter),
208
            3 => $this->calendar->$abbreviated[$quarter],
209
            4 => $this->calendar->$wide[$quarter],
210
            default => '',
211
        };
212
    }
213
214
    /**
215
     * Stand-Alone Quarter (q); One or two "q" for the numerical quarter, three for the abbreviation, or four for the
216
     * full (wide) name: [1..2, 3, 4]
217
     *
218
     * @uses Calendar::$standalone_abbreviated_quarters
219
     * @uses Calendar::$standalone_wide_quarters
220
     */
221
    private function format_standalone_quarter(DateTimeAccessor $datetime, int $length): string
222
    {
223
        return $this->format_quarter(
224
            datetime: $datetime,
225
            length: $length,
226
            abbreviated: 'standalone_abbreviated_quarters',
227
            wide: 'standalone_wide_quarters',
228
        );
229
    }
230
231
    /*
232
     * Month (M|L)
233
     */
234
235
    /**
236
     * Month (M); One or two "M" for the numerical month, three for the abbreviation, four for the full name, or five
237
     * for the narrow name: [1..2, 3, 4, 5]
238
     *
239
     * @uses Calendar::$abbreviated_months
240
     * @uses Calendar::$wide_months
241
     * @uses Calendar::$narrow_months
242
     */
243
    private function format_month(
244
        DateTimeAccessor $datetime,
245
        int $length,
246
        string $abbreviated = 'abbreviated_months',
247
        string $wide = 'wide_months',
248
        string $narrow = 'narrow_months'
249
    ): string {
250
        if ($length > 5) {
251
            return '';
252
        }
253
254
        $month = $datetime->month;
255
256
        switch ($length) {
257
            case 1:
258
                return (string)$month;
259
            case 2:
260
                return self::numeric_pad($month);
261
            case 3:
262
                $names = $this->calendar->$abbreviated;
263
                return $names[$month];
264
            case 4:
265
                $names = $this->calendar->$wide;
266
                return $names[$month];
267
            case 5:
268
                $names = $this->calendar->$narrow;
269
                return $names[$month];
270
        }
271
272
        return ''; // @codeCoverageIgnore
273
    }
274
275
    /**
276
     * Stand-Alone Month (L); One or two "L" for the numerical month, three for the abbreviation, or four for the full
277
     * (wide) name, or 5 for the narrow name: [1..2, 3, 4, 5]
278
     *
279
     * @uses Calendar::$standalone_abbreviated_months
280
     * @uses Calendar::$standalone_wide_months
281
     * @uses Calendar::$standalone_narrow_months
282
     */
283
    private function format_standalone_month(DateTimeAccessor $datetime, int $length): string
284
    {
285
        return $this->format_month(
286
            datetime: $datetime,
287
            length: $length,
288
            abbreviated: 'standalone_abbreviated_months',
289
            wide: 'standalone_wide_months',
290
            narrow: 'standalone_narrow_months'
291
        );
292
    }
293
294
    /*
295
     * Week (w|W)
296
     */
297
298
    /**
299
     * Week of the year (w); [1..2].
300
     */
301
    private function format_week_of_year(DateTimeAccessor $datetime, int $length): string
302
    {
303
        if ($length > 2) {
304
            return '';
305
        }
306
307
        $week = $datetime->week;
308
309
        return $length == 1 ? (string)$week : self::numeric_pad($week);
310
    }
311
312
    /**
313
     * Week of the month (W); [1].
314
     */
315
    private function format_week_of_month(DateTimeAccessor $datetime, int $length): string
316
    {
317
        if ($length > 1) {
318
            return '';
319
        }
320
321
        return (string)ceil($datetime->day / 7) ?: "0";
322
    }
323
324
    /*
325
     * Day (d,D,F)
326
     */
327
328
    /**
329
     * Day of the month (d); [1..2].
330
     */
331
    private function format_day_of_month(DateTimeAccessor $datetime, int $length): string
332
    {
333
        if ($length > 2) {
334
            return '';
335
        }
336
337
        $day = $datetime->day;
338
339
        if ($length == 1) {
340
            return (string)$day;
341
        }
342
343
        return self::numeric_pad($day);
344
    }
345
346
    /**
347
     * Day of the year (D); [1..3].
348
     */
349
    private function format_day_of_year(DateTimeAccessor $datetime, int $length): string
350
    {
351
        $day = $datetime->year_day;
352
353
        if ($length > 3) {
354
            return '';
355
        }
356
357
        return self::numeric_pad($day, $length);
358
    }
359
360
    /**
361
     * Day of the week in a month (F); For example, "2nd Wed in July": [1].
362
     */
363
    private function format_day_of_week_in_month(DateTimeAccessor $datetime, int $length): string
364
    {
365
        if ($length > 1) {
366
            return '';
367
        }
368
369
        return (string)floor(($datetime->day + 6) / 7);
370
    }
371
372
    /*
373
     * Weekday (E,e,c)
374
     */
375
376
    /**
377
     * Weekday (E); One through three letters for the short day, or four for the full name, five for the narrow name, or
378
     * six for the short name: [1..3, 4, 5, 6].
379
     */
380
    private function format_day_in_week(DateTimeAccessor $datetime, int $length): string
381
    {
382
        if ($length > 6) {
383
            return '';
384
        }
385
386
        $day = $datetime->weekday;
387
        $code = $this->name_for_day_code($day);
388
        $calendar = $this->calendar;
389
390
        return match ($length) {
391
            1, 2, 3 => $calendar->abbreviated_days[$code],
392
            4 => $calendar->wide_days[$code],
393
            5 => $calendar->narrow_days[$code],
394
            6 => $calendar->short_days[$code],
395
            default => '',
396
        };
397
        // @codeCoverageIgnore
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