Completed
Push — master ( 131d67...b52764 )
by René
02:59
created

Cron::parse()   A

Complexity

Conditions 4
Paths 5

Size

Total Lines 18
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

Changes 3
Bugs 0 Features 0
Metric Value
c 3
b 0
f 0
dl 0
loc 18
rs 9.2
cc 4
eloc 10
nc 5
nop 0
1
<?php
2
3
/**
4
 * Cron expression parser and validator
5
 *
6
 * @author René Pollesch
7
 */
8
class Cron
0 ignored issues
show
Coding Style Compatibility introduced by
PSR1 recommends that each class must be in a namespace of at least one level to avoid collisions.

You can fix this by adding a namespace to your class:

namespace YourVendor;

class YourClass { }

When choosing a vendor namespace, try to pick something that is not too generic to avoid conflicts with other libraries.

Loading history...
9
{
10
    /**
11
     * Weekday look-up table
12
     *
13
     * @var array
14
     */
15
    protected static $weekdays = [
16
        'sun' => 0,
17
        'mon' => 1,
18
        'tue' => 2,
19
        'wed' => 3,
20
        'thu' => 4,
21
        'fri' => 5,
22
        'sat' => 6
23
    ];
24
25
    /**
26
     * Month name look-up table
27
     *
28
     * @var array
29
     */
30
    protected static $months = [
31
        'jan' => 1,
32
        'feb' => 2,
33
        'mar' => 3,
34
        'apr' => 4,
35
        'may' => 5,
36
        'jun' => 6,
37
        'jul' => 7,
38
        'aug' => 8,
39
        'sep' => 9,
40
        'oct' => 10,
41
        'nov' => 11,
42
        'dec' => 12
43
    ];
44
45
    /**
46
     * Cron expression
47
     *
48
     * @var string
49
     */
50
    protected $expression;
51
52
    /**
53
     * Time zone
54
     *
55
     * @var \DateTimeZone
56
     */
57
    protected $timeZone;
58
59
    /**
60
     * Matching register
61
     *
62
     * @var array|null
63
     */
64
    protected $registers;
65
66
    /**
67
     * Class constructor sets cron expression property
68
     *
69
     * @param string $expression cron expression
70
     * @param \DateTimeZone $timeZone
71
     */
72
    public function __construct($expression = '* * * * *', \DateTimeZone $timeZone = null)
73
    {
74
        $this->setExpression($expression);
75
        $this->setTimeZone($timeZone);
76
    }
77
78
    /**
79
     * Set expression
80
     *
81
     * @param string $expression
82
     * @return self
83
     */
84
    public function setExpression($expression)
85
    {
86
        $this->expression = trim((string)$expression);
87
        $this->registers = null;
88
89
        return $this;
90
    }
91
92
    /**
93
     * Set time zone
94
     *
95
     * @param \DateTimeZone $timeZone
96
     * @return self
97
     */
98
    public function setTimeZone(\DateTimeZone $timeZone = null)
99
    {
100
        $this->timeZone = $timeZone;
101
        return $this;
102
    }
103
104
    /**
105
     * Parse and validate cron expression
106
     *
107
     * @return bool true if expression is valid, or false on error
108
     */
109
    public function isValid()
110
    {
111
        $result = true;
112
113
        if ($this->registers === null) {
114
            try {
115
                $this->registers = $this->parse();
116
            } catch (\Exception $e) {
117
                $result = false;
118
            }
119
        }
120
121
        return $result;
122
    }
123
124
    /**
125
     * Match current or given date/time against cron expression
126
     *
127
     * @param mixed $dtime \DateTime object, timestamp or null
128
     * @return bool
129
     */
130
    public function isMatching($dtime = null)
131
    {
132
        if ($dtime instanceof \DateTime) {
133
            $dtime->setTimezone($this->timeZone);
134
        } else {
135
            $dt = new \DateTime('now', $this->timeZone);
136
137
            if ((int)$dtime > 0) {
138
                $dt->setTimestamp($dtime);
139
            }
140
141
            $dtime = $dt;
142
        }
143
144
        $segments = sscanf($dtime->format('i G j n w'), '%d %d %d %d %d');
145
146
        try {
147
            $result = true;
148
149
            foreach ($this->parse() as $i => $item) {
150
                if (isset($item[(int)$segments[$i]]) === false) {
151
                    $result = false;
152
                    break;
153
                }
154
            }
155
        } catch (\Exception $e) {
156
            $result = false;
157
        }
158
159
        return $result;
160
    }
161
162
    /**
163
     * Calculate next matching timestamp
164
     *
165
     * @param mixed $dtime \DateTime object, timestamp or null
166
     * @return int|bool next matching timestamp, or false on error
167
     */
168
    public function getNext($dtime = null)
169
    {
170
        $result = false;
171
172
        if ($this->isValid()) {
173
            if ($dtime instanceof \DateTime) {
174
                $timestamp = $dtime->getTimestamp();
175
            } elseif ((int)$dtime > 0) {
176
                $timestamp = $dtime;
177
            } else {
178
                $timestamp = time();
179
            }
180
181
            $dt = new \DateTime('now', $this->timeZone);
182
            $dt->setTimestamp(ceil($timestamp / 60) * 60);
183
184
            list($pday, $pmonth, $pyear, $phour) = sscanf(
185
                $dt->format('j n Y G'),
186
                '%d %d %d %d'
187
            );
188
189
            while ($result === false) {
190
                list($minute, $hour, $day, $month, $year, $weekday) = sscanf(
191
                    $dt->format('i G j n Y w'),
192
                    '%d %d %d %d %d %d'
193
                );
194
195
                if ($pyear !== $year) {
196
                    $dt->setDate($year, 1, 1);
197
                    $dt->setTime(0, 0);
198
                } elseif ($pmonth !== $month) {
199
                    $dt->setDate($year, $month, 1);
200
                    $dt->setTime(0, 0);
201
                } elseif ($pday !== $day) {
202
                    $dt->setTime(0, 0);
203
                } elseif ($phour !== $hour) {
204
                    $dt->setTime($hour, 0);
205
                }
206
207
                list($pday, $pmonth, $pyear, $phour) = [$day, $month, $year, $hour];
208
209
                if (isset($this->registers[3][$month]) === false) {
210
                    $dt->modify('+1 month');
211
                    continue;
212
                } elseif (false === (isset($this->registers[2][$day]) && isset($this->registers[4][$weekday]))) {
213
                    $dt->modify('+1 day');
214
                    continue;
215
                } elseif (isset($this->registers[1][$hour]) === false) {
216
                    $dt->modify('+1 hour');
217
                    continue;
218
                } elseif (isset($this->registers[0][$minute]) === false) {
219
                    $dt->modify('+1 minute');
220
                    continue;
221
                }
222
223
                $result = $dt->getTimestamp();
224
            }
225
        }
226
227
        return $result;
228
    }
229
230
    /**
231
     * Parse cron expression and return expression parsed into matchable registers
232
     *
233
     * @return array
234
     * @throws \Exception
235
     */
236
    private function parse()
237
    {
238
        $registers = [];
239
240
        if (sizeof($segments = preg_split('/\s+/', $this->expression)) === 5) {
241
            foreach ($segments as $index => $segment) {
242
                $this->parseSegment($index, $segment, $registers);
243
            }
244
245
            if (isset($registers[4][7])) {
246
                $registers[4][0] = true;
247
            }
248
        } else {
249
            throw new \Exception('invalid number of segments');
250
        }
251
252
        return $registers;
253
    }
254
255
    /**
256
     * @param int $index
257
     * @param string $segment
258
     * @param array $registers
259
     * @throws \Exception
260
     */
261
    private function parseSegment($index, $segment, &$registers)
262
    {
263
        $minv = [0, 0, 1, 1, 0];
264
        $maxv = [59, 23, 31, 12, 7];
265
        $strv = [false, false, false, self::$months, self::$weekdays];
266
267
        // month names, weekdays
268
        if ($strv[$index] !== false && isset($strv[$index][strtolower($segment)])) {
269
            // cannot be used with lists or ranges, see crontab(5) man page
270
            $registers[$index][$strv[$index][strtolower($segment)]] = true;
271
        } else {
272
            // split up list into segments (e.g. "1,3-5,9")
273
            foreach (explode(',', $segment) as $listsegment) {
274
                // parse stepping notation
275
                if (strpos($listsegment, '/') !== false) {
276
                    if (sizeof($stepsegments = explode('/', $listsegment)) === 2) {
277
                        $listsegment = $stepsegments[0];
278
279
                        if (is_numeric($stepsegments[1])) {
280
                            if ($stepsegments[1] > 0 && $stepsegments[1] <= $maxv[$index]) {
281
                                $steps = intval($stepsegments[1]);
282
                            } else {
283
                                throw new \Exception('stepping value out of allowed range');
284
                            }
285
                        } else {
286
                            throw new \Exception('non-numeric stepping notation');
287
                        }
288
                    } else {
289
                        throw new \Exception('invalid stepping notation');
290
                    }
291
                } else {
292
                    $steps = 1;
293
                }
294
295
                // single value
296
                if (is_numeric($listsegment)) {
297
                    if (intval($listsegment) < $minv[$index] || intval($listsegment) > $maxv[$index]) {
298
                        throw new \Exception('value out of allowed range');
299
                    }
300
301
                    if ($steps !== 1) {
302
                        throw new \Exception('invalid combination of value and stepping notation');
303
                    }
304
305
                    $registers[$index][intval($listsegment)] = true;
306
                } else {
307
                    // asterisk indicates full range of values
308
                    if ($listsegment === '*') {
309
                        $listsegment = sprintf('%d-%d', $minv[$index], $maxv[$index]);
310
                    }
311
312
                    // range of values, e.g. "9-17"
313
                    if (strpos($listsegment, '-') !== false) {
314
                        if (sizeof($ranges = explode('-', $listsegment)) !== 2) {
315
                            throw new \Exception('invalid range notation');
316
                        }
317
318
                        // validate range
319
                        foreach ($ranges as $range) {
320
                            if (is_numeric($range)) {
321
                                if (intval($range) < $minv[$index] || intval($range) > $maxv[$index]) {
322
                                    throw new \Exception('invalid range start or end value');
323
                                }
324
                            } else {
325
                                throw new \Exception('non-numeric range notation');
326
                            }
327
                        }
328
329
                        // fill matching register
330
                        if ($ranges[0] === $ranges[1]) {
331
                            $registers[$index][$ranges[0]] = true;
332
                        } else {
333
                            for ($i = $minv[$index]; $i <= $maxv[$index]; $i++) {
334
                                if (($i - $ranges[0]) % $steps === 0) {
335
                                    if ($ranges[0] < $ranges[1]) {
336 View Code Duplication
                                        if ($i >= $ranges[0] && $i <= $ranges[1]) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
337
                                            $registers[$index][$i] = true;
338
                                        }
339 View Code Duplication
                                    } elseif ($i >= $ranges[0] || $i <= $ranges[1]) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
340
                                        $registers[$index][$i] = true;
341
                                    }
342
                                }
343
                            }
344
                        }
345
                    } else {
346
                        throw new \Exception('failed to parse list segment');
347
                    }
348
                }
349
            }
350
        }
351
    }
352
}
353