Passed
Push — 6.0 ( a5ddb0...4484d6 )
by Olivier
01:40
created

DateTimeFormatter::do_format_month()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 21
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 9
nc 2
nop 5
dl 0
loc 21
rs 9.9666
c 0
b 0
f 0
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
     * @link 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
    private function format_month(DateTimeAccessor $datetime, int $length): string
240
    {
241
        return $this->do_format_month(
242
            datetime: $datetime,
243
            length: $length,
244
            abbreviated: $this->calendar->abbreviated_months,
245
            wide: $this->calendar->wide_months,
246
            narrow: $this->calendar->narrow_months,
247
        );
248
    }
249
250
    /**
251
     * Stand-Alone Month (L); One or two "L" for the numerical month, three for the abbreviation, or four for the full
252
     * (wide) name, or 5 for the narrow name: [1..2, 3, 4, 5]
253
     */
254
    private function format_standalone_month(DateTimeAccessor $datetime, int $length): string
255
    {
256
        return $this->do_format_month(
257
            datetime: $datetime,
258
            length: $length,
259
            abbreviated: $this->calendar->standalone_abbreviated_months,
260
            wide: $this->calendar->standalone_wide_months,
261
            narrow: $this->calendar->standalone_narrow_months,
262
        );
263
    }
264
265
    /**
266
     * @param array<int, string> $abbreviated
267
     * @param array<int, string> $wide
268
     * @param array<int, string> $narrow
269
     */
270
    private function do_format_month(
271
        DateTimeAccessor $datetime,
272
        int $length,
273
        array $abbreviated,
274
        array $wide,
275
        array $narrow,
276
    ): string {
277
        if ($length > 5) {
278
            return '';
279
        }
280
281
        /** @var int<1, 5> $length */
282
283
        $month = $datetime->month;
284
285
        return match ($length) {
286
            1 => (string)$month,
287
            2 => self::numeric_pad($month),
288
            3 => $abbreviated[$month],
289
            4 => $wide[$month],
290
            5 => $narrow[$month],
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
    }
398
399
    /**
400
     * Stand-Alone weekday (e); One letter for the local numeric value (same as 'e'), three for the abbreviated day
401
     * name, four for the full (wide) name, five for the narrow name, or six for the short name.
402
     */
403
    private function format_day_in_week_stand_alone(DateTimeAccessor $datetime, int $length): string
404
    {
405
        if ($length == 2 || $length > 6) {
406
            return '';
407
        }
408
409
        $day = $datetime->weekday;
410
411
        if ($length == 1) {
412
            return (string)$day;
413
        }
414
415
        /** @var int<3, 6> $length */
416
417
        $days = match ($length) {
418
            3 => $this->calendar->standalone_abbreviated_days,
419
            4 => $this->calendar->standalone_wide_days,
420
            5 => $this->calendar->standalone_narrow_days,
421
            6 => $this->calendar->standalone_short_days,
422
        };
423
424
        $code = $this->name_for_day_code($day);
425
426
        return $days[$code];
427
    }
428
429
    /**
430
     * Local day of the week (c); Same as E except adds a numeric value that will depend on the local starting day of
431
     * the week, using one or two letters; For example, Monday is the first day of the week.
432
     */
433
    private function format_day_in_week_local(DateTimeAccessor $datetime, int $length): string
434
    {
435
        if ($length < 3) {
436
            return (string)$datetime->weekday;
437
        }
438
439
        return $this->format_day_in_week($datetime, $length);
440
    }
441
442
    /**
443
     * Period (a); AM or PM. [1]
444
     *
445
     * @return string AM or PM designator.
446
     */
447
    private function format_period(DateTimeAccessor $datetime): string
448
    {
449
        return $this->calendar['dayPeriods']['format']['abbreviated'][$datetime->hour < 12 ? 'am' : 'pm'];
450
    }
451
452
    /*
453
     * hour (h,H,K,k)
454
     */
455
456
    /**
457
     * Hour [1-12] (h); When used in skeleton data or in a skeleton passed in an API for flexible data pattern
458
     * generation, it should match the 12-hour-cycle format preferred by the locale (h or K); it shouldn't match a
459
     * 24-hour-cycle format (H or k).
460
     *
461
     * Use "hh" for zero-padding; [1..2].
462
     */
463
    private function format_hour12(DateTimeAccessor $datetime, int $length): string
464
    {
465
        if ($length > 2) {
466
            return '';
467
        }
468
469
        $hour = $datetime->hour;
470
        $hour = ($hour == 12) ? 12 : $hour % 12;
471
472
        if ($length == 1) {
473
            return (string)$hour;
474
        }
475
476
        return self::numeric_pad($hour);
477
    }
478
479
    /**
480
     * Hour [0-23] (H); When used in skeleton data or in a skeleton passed in an API for flexible data pattern
481
     * generation, it should match the 24-hour-cycle format preferred by the locale (H or k); it shouldn't match a
482
     * 12-hour-cycle format (h or K).
483
     *
484
     * Use "HH" for zero-padding; [1..2].
485
     */
486
    private function format_hour24(DateTimeAccessor $datetime, int $length): string
487
    {
488
        if ($length > 2) {
489
            return '';
490
        }
491
492
        $hour = $datetime->hour;
493
494
        if ($length == 1) {
495
            return (string)$hour;
496
        }
497
498
        return self::numeric_pad($hour);
499
    }
500
501
    /**
502
     * Hour [0-11] (K); When used in a skeleton, only matches K or h, see above.
503
     *
504
     * Use "KK" for zero-padding; [1..2].
505
     */
506
    private function format_hour_in_period(DateTimeAccessor $datetime, int $length): string
507
    {
508
        if ($length > 2) {
509
            return '';
510
        }
511
512
        $hour = $datetime->hour % 12;
513
514
        if ($length == 1) {
515
            return (string)$hour;
516
        }
517
518
        return self::numeric_pad($hour);
519
    }
520
521
    /**
522
     * Hour [1-24] (k); When used in a skeleton, only matches k or H, see above.
523
     *
524
     * Use "kk" for zero-padding; [1..2].
525
     */
526
    private function format_hour_in_day(DateTimeAccessor $datetime, int $length): string
527
    {
528
        if ($length > 2) {
529
            return '';
530
        }
531
532
        $hour = $datetime->hour ?: 24;
533
534
        if ($length == 1) {
535
            return (string)$hour;
536
        }
537
538
        return self::numeric_pad($hour);
539
    }
540
541
    /**
542
     * Minute (m); One or two "m" for zero-padding.
543
     */
544
    private function format_minutes(DateTimeAccessor $datetime, int $length): string
545
    {
546
        return $this->format_minutes_or_seconds($datetime, $length, 'minute');
547
    }
548
549
    /**
550
     * Second (s); One or two "s" for zero-padding.
551
     */
552
    private function format_seconds(DateTimeAccessor $datetime, int $length): string
553
    {
554
        return $this->format_minutes_or_seconds($datetime, $length, 'second');
555
    }
556
557
    /**
558
     * Minute (m); One or two "m" for zero-padding.
559
     */
560
    private function format_minutes_or_seconds(DateTimeAccessor $datetime, int $length, string $which): string
561
    {
562
        if ($length > 2) {
563
            return '';
564
        }
565
566
        $value = $datetime->$which;
567
568
        if ($length == 1) {
569
            return $value;
570
        }
571
572
        return self::numeric_pad($value);
573
    }
574
575
    /*
576
     * zone (z,Z,v)
577
     */
578
579
    /**
580
     * The ISO8601 basic format (z).
581
     */
582
    private function format_timezone_basic(DateTimeAccessor $datetime): string
583
    {
584
        return $datetime->format('O');
585
    }
586
587
    /**
588
     * The specific non-location format (Z, v).
589
     */
590
    private function format_timezone_non_location(DateTimeAccessor $datetime): string
591
    {
592
        $str = $datetime->format('T');
593
594
        return $str === 'Z' ? 'UTC' : $str;
595
    }
596
597
    /**
598
     * @throws \Exception if the {@see DateTimeImmutable} instance can't be created.
599
     */
600
    private function ensure_datetime(float|int|string|DateTimeInterface $datetime): DateTimeInterface
601
    {
602
        if ($datetime instanceof DateTimeInterface) {
603
            return $datetime;
604
        }
605
606
        return new DateTimeImmutable(is_numeric($datetime) ? "@$datetime" : (string)$datetime);
607
    }
608
609
    /**
610
     * @param int<1, 7> $day
611
     *     A day code.
612
     */
613
    private function name_for_day_code(int $day): string
614
    {
615
        return match ($day) {
616
            1 => 'mon',
617
            2 => 'tue',
618
            3 => 'wed',
619
            4 => 'thu',
620
            5 => 'fri',
621
            6 => 'sat',
622
            7 => 'sun',
623
        };
624
    }
625
}
626