1<?php
2
3namespace Cron;
4
5use DateTime;
6use DateTimeImmutable;
7use DateTimeZone;
8use Exception;
9use InvalidArgumentException;
10use RuntimeException;
11
12/**
13 * CRON expression parser that can determine whether or not a CRON expression is
14 * due to run, the next run date and previous run date of a CRON expression.
15 * The determinations made by this class are accurate if checked run once per
16 * minute (seconds are dropped from date time comparisons).
17 *
18 * Schedule parts must map to:
19 * minute [0-59], hour [0-23], day of month, month [1-12|JAN-DEC], day of week
20 * [1-7|MON-SUN], and an optional year.
21 *
22 * @link http://en.wikipedia.org/wiki/Cron
23 */
24class CronExpression
25{
26    const MINUTE = 0;
27    const HOUR = 1;
28    const DAY = 2;
29    const MONTH = 3;
30    const WEEKDAY = 4;
31    const YEAR = 5;
32
33    /**
34     * @var array CRON expression parts
35     */
36    private $cronParts;
37
38    /**
39     * @var FieldFactory CRON field factory
40     */
41    private $fieldFactory;
42
43    /**
44     * @var int Max iteration count when searching for next run date
45     */
46    private $maxIterationCount = 1000;
47
48    /**
49     * @var array Order in which to test of cron parts
50     */
51    private static $order = array(self::YEAR, self::MONTH, self::DAY, self::WEEKDAY, self::HOUR, self::MINUTE);
52
53    /**
54     * Factory method to create a new CronExpression.
55     *
56     * @param string $expression The CRON expression to create.  There are
57     *                           several special predefined values which can be used to substitute the
58     *                           CRON expression:
59     *
60     *      `@yearly`, `@annually` - Run once a year, midnight, Jan. 1 - 0 0 1 1 *
61     *      `@monthly` - Run once a month, midnight, first of month - 0 0 1 * *
62     *      `@weekly` - Run once a week, midnight on Sun - 0 0 * * 0
63     *      `@daily` - Run once a day, midnight - 0 0 * * *
64     *      `@hourly` - Run once an hour, first minute - 0 * * * *
65     * @param FieldFactory $fieldFactory Field factory to use
66     *
67     * @return CronExpression
68     */
69    public static function factory($expression, FieldFactory $fieldFactory = null)
70    {
71        $mappings = array(
72            '@yearly' => '0 0 1 1 *',
73            '@annually' => '0 0 1 1 *',
74            '@monthly' => '0 0 1 * *',
75            '@weekly' => '0 0 * * 0',
76            '@daily' => '0 0 * * *',
77            '@hourly' => '0 * * * *'
78        );
79
80        if (isset($mappings[$expression])) {
81            $expression = $mappings[$expression];
82        }
83
84        return new static($expression, $fieldFactory ?: new FieldFactory());
85    }
86
87    /**
88     * Validate a CronExpression.
89     *
90     * @param string $expression The CRON expression to validate.
91     *
92     * @return bool True if a valid CRON expression was passed. False if not.
93     * @see \Cron\CronExpression::factory
94     */
95    public static function isValidExpression($expression)
96    {
97        try {
98            self::factory($expression);
99        } catch (InvalidArgumentException $e) {
100            return false;
101        }
102
103        return true;
104    }
105
106    /**
107     * Parse a CRON expression
108     *
109     * @param string       $expression   CRON expression (e.g. '8 * * * *')
110     * @param FieldFactory $fieldFactory Factory to create cron fields
111     */
112    public function __construct($expression, FieldFactory $fieldFactory)
113    {
114        $this->fieldFactory = $fieldFactory;
115        $this->setExpression($expression);
116    }
117
118    /**
119     * Set or change the CRON expression
120     *
121     * @param string $value CRON expression (e.g. 8 * * * *)
122     *
123     * @return CronExpression
124     * @throws \InvalidArgumentException if not a valid CRON expression
125     */
126    public function setExpression($value)
127    {
128        $this->cronParts = preg_split('/\s/', $value, -1, PREG_SPLIT_NO_EMPTY);
129        if (count($this->cronParts) < 5) {
130            throw new InvalidArgumentException(
131                $value . ' is not a valid CRON expression'
132            );
133        }
134
135        foreach ($this->cronParts as $position => $part) {
136            $this->setPart($position, $part);
137        }
138
139        return $this;
140    }
141
142    /**
143     * Set part of the CRON expression
144     *
145     * @param int    $position The position of the CRON expression to set
146     * @param string $value    The value to set
147     *
148     * @return CronExpression
149     * @throws \InvalidArgumentException if the value is not valid for the part
150     */
151    public function setPart($position, $value)
152    {
153        if (!$this->fieldFactory->getField($position)->validate($value)) {
154            throw new InvalidArgumentException(
155                'Invalid CRON field value ' . $value . ' at position ' . $position
156            );
157        }
158
159        $this->cronParts[$position] = $value;
160
161        return $this;
162    }
163
164    /**
165     * Set max iteration count for searching next run dates
166     *
167     * @param int $maxIterationCount Max iteration count when searching for next run date
168     *
169     * @return CronExpression
170     */
171    public function setMaxIterationCount($maxIterationCount)
172    {
173        $this->maxIterationCount = $maxIterationCount;
174
175        return $this;
176    }
177
178    /**
179     * Get a next run date relative to the current date or a specific date
180     *
181     * @param string|\DateTime $currentTime      Relative calculation date
182     * @param int              $nth              Number of matches to skip before returning a
183     *                                           matching next run date.  0, the default, will return the current
184     *                                           date and time if the next run date falls on the current date and
185     *                                           time.  Setting this value to 1 will skip the first match and go to
186     *                                           the second match.  Setting this value to 2 will skip the first 2
187     *                                           matches and so on.
188     * @param bool             $allowCurrentDate Set to TRUE to return the current date if
189     *                                           it matches the cron expression.
190     *
191     * @return \DateTime
192     * @throws \RuntimeException on too many iterations
193     */
194    public function getNextRunDate($currentTime = 'now', $nth = 0, $allowCurrentDate = false)
195    {
196        return $this->getRunDate($currentTime, $nth, false, $allowCurrentDate);
197    }
198
199    /**
200     * Get a previous run date relative to the current date or a specific date
201     *
202     * @param string|\DateTime $currentTime      Relative calculation date
203     * @param int              $nth              Number of matches to skip before returning
204     * @param bool             $allowCurrentDate Set to TRUE to return the
205     *                                           current date if it matches the cron expression
206     *
207     * @return \DateTime
208     * @throws \RuntimeException on too many iterations
209     * @see \Cron\CronExpression::getNextRunDate
210     */
211    public function getPreviousRunDate($currentTime = 'now', $nth = 0, $allowCurrentDate = false)
212    {
213        return $this->getRunDate($currentTime, $nth, true, $allowCurrentDate);
214    }
215
216    /**
217     * Get multiple run dates starting at the current date or a specific date
218     *
219     * @param int              $total            Set the total number of dates to calculate
220     * @param string|\DateTime $currentTime      Relative calculation date
221     * @param bool             $invert           Set to TRUE to retrieve previous dates
222     * @param bool             $allowCurrentDate Set to TRUE to return the
223     *                                           current date if it matches the cron expression
224     *
225     * @return array Returns an array of run dates
226     */
227    public function getMultipleRunDates($total, $currentTime = 'now', $invert = false, $allowCurrentDate = false)
228    {
229        $matches = array();
230        for ($i = 0; $i < max(0, $total); $i++) {
231            try {
232                $matches[] = $this->getRunDate($currentTime, $i, $invert, $allowCurrentDate);
233            } catch (RuntimeException $e) {
234                break;
235            }
236        }
237
238        return $matches;
239    }
240
241    /**
242     * Get all or part of the CRON expression
243     *
244     * @param string $part Specify the part to retrieve or NULL to get the full
245     *                     cron schedule string.
246     *
247     * @return string|null Returns the CRON expression, a part of the
248     *                     CRON expression, or NULL if the part was specified but not found
249     */
250    public function getExpression($part = null)
251    {
252        if (null === $part) {
253            return implode(' ', $this->cronParts);
254        } elseif (array_key_exists($part, $this->cronParts)) {
255            return $this->cronParts[$part];
256        }
257
258        return null;
259    }
260
261    /**
262     * Helper method to output the full expression.
263     *
264     * @return string Full CRON expression
265     */
266    public function __toString()
267    {
268        return $this->getExpression();
269    }
270
271    /**
272     * Determine if the cron is due to run based on the current date or a
273     * specific date.  This method assumes that the current number of
274     * seconds are irrelevant, and should be called once per minute.
275     *
276     * @param string|\DateTime $currentTime Relative calculation date
277     *
278     * @return bool Returns TRUE if the cron is due to run or FALSE if not
279     */
280    public function isDue($currentTime = 'now')
281    {
282        if ('now' === $currentTime) {
283            $currentDate = date('Y-m-d H:i');
284            $currentTime = strtotime($currentDate);
285        } elseif ($currentTime instanceof DateTime) {
286            $currentDate = clone $currentTime;
287            // Ensure time in 'current' timezone is used
288            $currentDate->setTimezone(new DateTimeZone(date_default_timezone_get()));
289            $currentDate = $currentDate->format('Y-m-d H:i');
290            $currentTime = strtotime($currentDate);
291        } elseif ($currentTime instanceof DateTimeImmutable) {
292            $currentDate = DateTime::createFromFormat('U', $currentTime->format('U'));
293            $currentDate->setTimezone(new DateTimeZone(date_default_timezone_get()));
294            $currentDate = $currentDate->format('Y-m-d H:i');
295            $currentTime = strtotime($currentDate);
296        } else {
297            $currentTime = new DateTime($currentTime);
298            $currentTime->setTime($currentTime->format('H'), $currentTime->format('i'), 0);
299            $currentDate = $currentTime->format('Y-m-d H:i');
300            $currentTime = $currentTime->getTimeStamp();
301        }
302
303        try {
304            return $this->getNextRunDate($currentDate, 0, true)->getTimestamp() == $currentTime;
305        } catch (Exception $e) {
306            return false;
307        }
308    }
309
310    /**
311     * Get the next or previous run date of the expression relative to a date
312     *
313     * @param string|\DateTime $currentTime      Relative calculation date
314     * @param int              $nth              Number of matches to skip before returning
315     * @param bool             $invert           Set to TRUE to go backwards in time
316     * @param bool             $allowCurrentDate Set to TRUE to return the
317     *                                           current date if it matches the cron expression
318     *
319     * @return \DateTime
320     * @throws \RuntimeException on too many iterations
321     */
322    protected function getRunDate($currentTime = null, $nth = 0, $invert = false, $allowCurrentDate = false)
323    {
324        if ($currentTime instanceof DateTime) {
325            $currentDate = clone $currentTime;
326        } elseif ($currentTime instanceof DateTimeImmutable) {
327            $currentDate = DateTime::createFromFormat('U', $currentTime->format('U'));
328            $currentDate->setTimezone($currentTime->getTimezone());
329        } else {
330            $currentDate = new DateTime($currentTime ?: 'now');
331            $currentDate->setTimezone(new DateTimeZone(date_default_timezone_get()));
332        }
333
334        $currentDate->setTime($currentDate->format('H'), $currentDate->format('i'), 0);
335        $nextRun = clone $currentDate;
336        $nth = (int) $nth;
337
338        // We don't have to satisfy * or null fields
339        $parts = array();
340        $fields = array();
341        foreach (self::$order as $position) {
342            $part = $this->getExpression($position);
343            if (null === $part || '*' === $part) {
344                continue;
345            }
346            $parts[$position] = $part;
347            $fields[$position] = $this->fieldFactory->getField($position);
348        }
349
350        // Set a hard limit to bail on an impossible date
351        for ($i = 0; $i < $this->maxIterationCount; $i++) {
352
353            foreach ($parts as $position => $part) {
354                $satisfied = false;
355                // Get the field object used to validate this part
356                $field = $fields[$position];
357                // Check if this is singular or a list
358                if (strpos($part, ',') === false) {
359                    $satisfied = $field->isSatisfiedBy($nextRun, $part);
360                } else {
361                    foreach (array_map('trim', explode(',', $part)) as $listPart) {
362                        if ($field->isSatisfiedBy($nextRun, $listPart)) {
363                            $satisfied = true;
364                            break;
365                        }
366                    }
367                }
368
369                // If the field is not satisfied, then start over
370                if (!$satisfied) {
371                    $field->increment($nextRun, $invert, $part);
372                    continue 2;
373                }
374            }
375
376            // Skip this match if needed
377            if ((!$allowCurrentDate && $nextRun == $currentDate) || --$nth > -1) {
378                $this->fieldFactory->getField(0)->increment($nextRun, $invert, isset($parts[0]) ? $parts[0] : null);
379                continue;
380            }
381
382            return $nextRun;
383        }
384
385        // @codeCoverageIgnoreStart
386        throw new RuntimeException('Impossible CRON expression');
387        // @codeCoverageIgnoreEnd
388    }
389}
390