1<?php
2
3/*
4 * This file is part of the Symfony package.
5 *
6 * (c) Fabien Potencier <fabien@symfony.com>
7 *
8 * For the full copyright and license information, please view the LICENSE
9 * file that was distributed with this source code.
10 */
11
12namespace Symfony\Polyfill\Intl\Icu\DateFormat;
13
14use Symfony\Polyfill\Intl\Icu\Exception\NotImplementedException;
15use Symfony\Polyfill\Intl\Icu\Icu;
16
17/**
18 * Parser and formatter for date formats.
19 *
20 * @author Igor Wiedler <igor@wiedler.ch>
21 *
22 * @internal
23 */
24class FullTransformer
25{
26    private $quoteMatch = "'(?:[^']+|'')*'";
27    private $implementedChars = 'MLydQqhDEaHkKmsz';
28    private $notImplementedChars = 'GYuwWFgecSAZvVW';
29    private $regExp;
30
31    /**
32     * @var Transformer[]
33     */
34    private $transformers;
35
36    private $pattern;
37    private $timezone;
38
39    /**
40     * @param string $pattern  The pattern to be used to format and/or parse values
41     * @param string $timezone The timezone to perform the date/time calculations
42     */
43    public function __construct(string $pattern, string $timezone)
44    {
45        $this->pattern = $pattern;
46        $this->timezone = $timezone;
47
48        $implementedCharsMatch = $this->buildCharsMatch($this->implementedChars);
49        $notImplementedCharsMatch = $this->buildCharsMatch($this->notImplementedChars);
50        $this->regExp = "/($this->quoteMatch|$implementedCharsMatch|$notImplementedCharsMatch)/";
51
52        $this->transformers = [
53            'M' => new MonthTransformer(),
54            'L' => new MonthTransformer(),
55            'y' => new YearTransformer(),
56            'd' => new DayTransformer(),
57            'q' => new QuarterTransformer(),
58            'Q' => new QuarterTransformer(),
59            'h' => new Hour1201Transformer(),
60            'D' => new DayOfYearTransformer(),
61            'E' => new DayOfWeekTransformer(),
62            'a' => new AmPmTransformer(),
63            'H' => new Hour2400Transformer(),
64            'K' => new Hour1200Transformer(),
65            'k' => new Hour2401Transformer(),
66            'm' => new MinuteTransformer(),
67            's' => new SecondTransformer(),
68            'z' => new TimezoneTransformer(),
69        ];
70    }
71
72    /**
73     * Format a DateTime using ICU dateformat pattern.
74     *
75     * @return string The formatted value
76     */
77    public function format(\DateTime $dateTime): string
78    {
79        $formatted = preg_replace_callback($this->regExp, function ($matches) use ($dateTime) {
80            return $this->formatReplace($matches[0], $dateTime);
81        }, $this->pattern);
82
83        return $formatted;
84    }
85
86    /**
87     * Return the formatted ICU value for the matched date characters.
88     *
89     * @throws NotImplementedException When it encounters a not implemented date character
90     */
91    private function formatReplace(string $dateChars, \DateTime $dateTime): string
92    {
93        $length = \strlen($dateChars);
94
95        if ($this->isQuoteMatch($dateChars)) {
96            return $this->replaceQuoteMatch($dateChars);
97        }
98
99        if (isset($this->transformers[$dateChars[0]])) {
100            $transformer = $this->transformers[$dateChars[0]];
101
102            return $transformer->format($dateTime, $length);
103        }
104
105        // handle unimplemented characters
106        if (false !== strpos($this->notImplementedChars, $dateChars[0])) {
107            throw new NotImplementedException(sprintf('Unimplemented date character "%s" in format "%s".', $dateChars[0], $this->pattern));
108        }
109
110        return '';
111    }
112
113    /**
114     * Parse a pattern based string to a timestamp value.
115     *
116     * @param \DateTime $dateTime A configured DateTime object to use to perform the date calculation
117     * @param string    $value    String to convert to a time value
118     *
119     * @return int|false The corresponding Unix timestamp
120     *
121     * @throws \InvalidArgumentException When the value can not be matched with pattern
122     */
123    public function parse(\DateTime $dateTime, string $value)
124    {
125        $reverseMatchingRegExp = $this->getReverseMatchingRegExp($this->pattern);
126        $reverseMatchingRegExp = '/^'.$reverseMatchingRegExp.'$/';
127
128        $options = [];
129
130        if (preg_match($reverseMatchingRegExp, $value, $matches)) {
131            $matches = $this->normalizeArray($matches);
132
133            foreach ($this->transformers as $char => $transformer) {
134                if (isset($matches[$char])) {
135                    $length = \strlen($matches[$char]['pattern']);
136                    $options = array_merge($options, $transformer->extractDateOptions($matches[$char]['value'], $length));
137                }
138            }
139
140            // reset error code and message
141            Icu::setError(Icu::U_ZERO_ERROR);
142
143            return $this->calculateUnixTimestamp($dateTime, $options);
144        }
145
146        // behave like the intl extension
147        Icu::setError(Icu::U_PARSE_ERROR, 'Date parsing failed');
148
149        return false;
150    }
151
152    /**
153     * Retrieve a regular expression to match with a formatted value.
154     *
155     * @return string The reverse matching regular expression with named captures being formed by the
156     *                transformer index in the $transformer array
157     */
158    private function getReverseMatchingRegExp(string $pattern): string
159    {
160        $escapedPattern = preg_quote($pattern, '/');
161
162        // ICU 4.8 recognizes slash ("/") in a value to be parsed as a dash ("-") and vice-versa
163        // when parsing a date/time value
164        $escapedPattern = preg_replace('/\\\[\-|\/]/', '[\/\-]', $escapedPattern);
165
166        $reverseMatchingRegExp = preg_replace_callback($this->regExp, function ($matches) {
167            $length = \strlen($matches[0]);
168            $transformerIndex = $matches[0][0];
169
170            $dateChars = $matches[0];
171            if ($this->isQuoteMatch($dateChars)) {
172                return $this->replaceQuoteMatch($dateChars);
173            }
174
175            if (isset($this->transformers[$transformerIndex])) {
176                $transformer = $this->transformers[$transformerIndex];
177                $captureName = str_repeat($transformerIndex, $length);
178
179                return "(?P<$captureName>".$transformer->getReverseMatchingRegExp($length).')';
180            }
181
182            return null;
183        }, $escapedPattern);
184
185        return $reverseMatchingRegExp;
186    }
187
188    /**
189     * Check if the first char of a string is a single quote.
190     */
191    private function isQuoteMatch(string $quoteMatch): bool
192    {
193        return "'" === $quoteMatch[0];
194    }
195
196    /**
197     * Replaces single quotes at the start or end of a string with two single quotes.
198     */
199    private function replaceQuoteMatch(string $quoteMatch): string
200    {
201        if (preg_match("/^'+$/", $quoteMatch)) {
202            return str_replace("''", "'", $quoteMatch);
203        }
204
205        return str_replace("''", "'", substr($quoteMatch, 1, -1));
206    }
207
208    /**
209     * Builds a chars match regular expression.
210     */
211    private function buildCharsMatch(string $specialChars): string
212    {
213        $specialCharsArray = str_split($specialChars);
214
215        $specialCharsMatch = implode('|', array_map(function ($char) {
216            return $char.'+';
217        }, $specialCharsArray));
218
219        return $specialCharsMatch;
220    }
221
222    /**
223     * Normalize a preg_replace match array, removing the numeric keys and returning an associative array
224     * with the value and pattern values for the matched Transformer.
225     */
226    private function normalizeArray(array $data): array
227    {
228        $ret = [];
229
230        foreach ($data as $key => $value) {
231            if (!\is_string($key)) {
232                continue;
233            }
234
235            $ret[$key[0]] = [
236                'value' => $value,
237                'pattern' => $key,
238            ];
239        }
240
241        return $ret;
242    }
243
244    /**
245     * Calculates the Unix timestamp based on the matched values by the reverse matching regular
246     * expression of parse().
247     *
248     * @return bool|int The calculated timestamp or false if matched date is invalid
249     */
250    private function calculateUnixTimestamp(\DateTime $dateTime, array $options)
251    {
252        $options = $this->getDefaultValueForOptions($options);
253
254        $year = $options['year'];
255        $month = $options['month'];
256        $day = $options['day'];
257        $hour = $options['hour'];
258        $hourInstance = $options['hourInstance'];
259        $minute = $options['minute'];
260        $second = $options['second'];
261        $marker = $options['marker'];
262        $timezone = $options['timezone'];
263
264        // If month is false, return immediately (intl behavior)
265        if (false === $month) {
266            Icu::setError(Icu::U_PARSE_ERROR, 'Date parsing failed');
267
268            return false;
269        }
270
271        // Normalize hour
272        if ($hourInstance instanceof HourTransformer) {
273            $hour = $hourInstance->normalizeHour($hour, $marker);
274        }
275
276        // Set the timezone if different from the default one
277        if (null !== $timezone && $timezone !== $this->timezone) {
278            $dateTime->setTimezone(new \DateTimeZone($timezone));
279        }
280
281        // Normalize yy year
282        preg_match_all($this->regExp, $this->pattern, $matches);
283        if (\in_array('yy', $matches[0])) {
284            $dateTime->setTimestamp(time());
285            $year = $year > (int) $dateTime->format('y') + 20 ? 1900 + $year : 2000 + $year;
286        }
287
288        $dateTime->setDate($year, $month, $day);
289        $dateTime->setTime($hour, $minute, $second);
290
291        return $dateTime->getTimestamp();
292    }
293
294    /**
295     * Add sensible default values for missing items in the extracted date/time options array. The values
296     * are base in the beginning of the Unix era.
297     */
298    private function getDefaultValueForOptions(array $options): array
299    {
300        return [
301            'year' => $options['year'] ?? 1970,
302            'month' => $options['month'] ?? 1,
303            'day' => $options['day'] ?? 1,
304            'hour' => $options['hour'] ?? 0,
305            'hourInstance' => $options['hourInstance'] ?? null,
306            'minute' => $options['minute'] ?? 0,
307            'second' => $options['second'] ?? 0,
308            'marker' => $options['marker'] ?? null,
309            'timezone' => $options['timezone'] ?? null,
310        ];
311    }
312}
313