Completed
Push — master ( 9aa418...979aa5 )
by René
03:01
created

Cron::validateRange()   B

Complexity

Conditions 6
Paths 5

Size

Total Lines 17
Code Lines 10

Duplication

Lines 4
Ratio 23.53 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
c 1
b 0
f 0
dl 4
loc 17
rs 8.8571
cc 6
eloc 10
nc 5
nop 2
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
     * Value boundaries
47
     *
48
     * @var array
49
     */
50
    protected static $boundaries = [
51
        0 => [
52
            'min' => 0,
53
            'max' => 59
54
        ],
55
        1 => [
56
            'min' => 0,
57
            'max' => 23
58
        ],
59
        2 => [
60
            'min' => 1,
61
            'max' => 31
62
        ],
63
        3 => [
64
            'min' => 1,
65
            'max' => 12
66
        ],
67
        4 => [
68
            'min' => 0,
69
            'max' => 7
70
        ]
71
    ];
72
73
    /**
74
     * Cron expression
75
     *
76
     * @var string
77
     */
78
    protected $expression;
79
80
    /**
81
     * Time zone
82
     *
83
     * @var \DateTimeZone
84
     */
85
    protected $timeZone;
86
87
    /**
88
     * Matching register
89
     *
90
     * @var array|null
91
     */
92
    protected $register;
93
94
    /**
95
     * Class constructor sets cron expression property
96
     *
97
     * @param string $expression cron expression
98
     * @param \DateTimeZone $timeZone
99
     */
100
    public function __construct($expression = '* * * * *', \DateTimeZone $timeZone = null)
101
    {
102
        $this->setExpression($expression);
103
        $this->setTimeZone($timeZone);
104
    }
105
106
    /**
107
     * Set expression
108
     *
109
     * @param string $expression
110
     * @return self
111
     */
112
    public function setExpression($expression)
113
    {
114
        $this->expression = trim((string)$expression);
115
        $this->register = null;
116
117
        return $this;
118
    }
119
120
    /**
121
     * Set time zone
122
     *
123
     * @param \DateTimeZone $timeZone
124
     * @return self
125
     */
126
    public function setTimeZone(\DateTimeZone $timeZone = null)
127
    {
128
        $this->timeZone = $timeZone;
129
        return $this;
130
    }
131
132
    /**
133
     * Parse and validate cron expression
134
     *
135
     * @return bool true if expression is valid, or false on error
136
     */
137
    public function isValid()
138
    {
139
        $result = true;
140
141
        if ($this->register === null) {
142
            try {
143
                $this->register = $this->parse();
144
            } catch (\Exception $e) {
145
                $result = false;
146
            }
147
        }
148
149
        return $result;
150
    }
151
152
    /**
153
     * Match current or given date/time against cron expression
154
     *
155
     * @param mixed $dtime \DateTime object, timestamp or null
156
     * @return bool
157
     */
158
    public function isMatching($dtime = null)
159
    {
160
        if ($dtime instanceof \DateTime) {
161
            $dtime->setTimezone($this->timeZone);
162
        } else {
163
            $dt = new \DateTime('now', $this->timeZone);
164
165
            if ((int)$dtime > 0) {
166
                $dt->setTimestamp($dtime);
167
            }
168
169
            $dtime = $dt;
170
        }
171
172
        $segments = sscanf($dtime->format('i G j n w'), '%d %d %d %d %d');
173
174
        try {
175
            $result = true;
176
177
            foreach ($this->parse() as $i => $item) {
178
                if (isset($item[(int)$segments[$i]]) === false) {
179
                    $result = false;
180
                    break;
181
                }
182
            }
183
        } catch (\Exception $e) {
184
            $result = false;
185
        }
186
187
        return $result;
188
    }
189
190
    /**
191
     * Calculate next matching timestamp
192
     *
193
     * @param mixed $dtime \DateTime object, timestamp or null
194
     * @return int|bool next matching timestamp, or false on error
195
     */
196
    public function getNext($dtime = null)
197
    {
198
        $result = false;
199
200
        if ($this->isValid()) {
201
            if ($dtime instanceof \DateTime) {
202
                $timestamp = $dtime->getTimestamp();
203
            } elseif ((int)$dtime > 0) {
204
                $timestamp = $dtime;
205
            } else {
206
                $timestamp = time();
207
            }
208
209
            $dt = new \DateTime('now', $this->timeZone);
210
            $dt->setTimestamp(ceil($timestamp / 60) * 60);
211
212
            list($pday, $pmonth, $pyear, $phour) = sscanf(
213
                $dt->format('j n Y G'),
214
                '%d %d %d %d'
215
            );
216
217
            while ($result === false) {
218
                list($minute, $hour, $day, $month, $year, $weekday) = sscanf(
219
                    $dt->format('i G j n Y w'),
220
                    '%d %d %d %d %d %d'
221
                );
222
223
                if ($pyear !== $year) {
224
                    $dt->setDate($year, 1, 1);
225
                    $dt->setTime(0, 0);
226
                } elseif ($pmonth !== $month) {
227
                    $dt->setDate($year, $month, 1);
228
                    $dt->setTime(0, 0);
229
                } elseif ($pday !== $day) {
230
                    $dt->setTime(0, 0);
231
                } elseif ($phour !== $hour) {
232
                    $dt->setTime($hour, 0);
233
                }
234
235
                list($pday, $pmonth, $pyear, $phour) = [$day, $month, $year, $hour];
236
237
                if (isset($this->register[3][$month]) === false) {
238
                    $dt->modify('+1 month');
239
                    continue;
240
                } elseif (false === (isset($this->register[2][$day]) && isset($this->register[4][$weekday]))) {
241
                    $dt->modify('+1 day');
242
                    continue;
243
                } elseif (isset($this->register[1][$hour]) === false) {
244
                    $dt->modify('+1 hour');
245
                    continue;
246
                } elseif (isset($this->register[0][$minute]) === false) {
247
                    $dt->modify('+1 minute');
248
                    continue;
249
                }
250
251
                $result = $dt->getTimestamp();
252
            }
253
        }
254
255
        return $result;
256
    }
257
258
    /**
259
     * Parse whole cron expression
260
     *
261
     * @return array
262
     * @throws \Exception
263
     */
264
    private function parse()
265
    {
266
        $register = [];
267
268
        if (sizeof($segments = preg_split('/\s+/', $this->expression)) === 5) {
269
            foreach ($segments as $index => $segment) {
270
                $this->parseSegment($index, $register, $segment);
271
            }
272
273
            $register[4][0] = isset($register[4][7]);
274
        } else {
275
            throw new \Exception('invalid number of segments');
276
        }
277
278
        return $register;
279
    }
280
281
    /**
282
     * @param int $index
283
     * @param string $segment
284
     * @param array $register
285
     * @throws \Exception
286
     */
287
    private function parseSegment($index, &$register, $segment)
288
    {
289
        $strv = [false, false, false, self::$months, self::$weekdays];
290
291
        // month names, weekdays
292
        if ($strv[$index] !== false && isset($strv[$index][strtolower($segment)])) {
293
            // cannot be used with lists or ranges, see crontab(5) man page
294
            $register[$index][$strv[$index][strtolower($segment)]] = true;
295
        } else {
296
            // split up current segment into single elements, e.g. "1,5-7,*/2" => [ "1", "5-7", "*/2" ]
297
            foreach (explode(',', $segment) as $element) {
298
                $this->parseElement($index, $register, $element);
299
            }
300
        }
301
    }
302
303
    /**
304
     * @param int $index
305
     * @param string $element
306
     * @param array $register
307
     * @throws \Exception
308
     */
309
    private function parseElement($index, array &$register, $element)
310
    {
311
        $stepping = $this->parseStepping($index, $element);
312
313
        // single value
314
        if (is_numeric($element)) {
315 View Code Duplication
            if (intval($element) < self::$boundaries[$index]['min'] ||
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...
316
                intval($element) > self::$boundaries[$index]['max']) {
317
                throw new \Exception('value out of allowed range');
318
            }
319
320
            if ($stepping !== 1) {
321
                throw new \Exception('invalid combination of value and stepping notation');
322
            }
323
324
            $register[$index][intval($element)] = true;
325
        } else {
326
            if ($element === '*') {
327
                $range = [self::$boundaries[$index]['min'], self::$boundaries[$index]['max']];
328
            } elseif (strpos($element, '-') !== false) {
329
                $range = explode('-', $element);
330
            } else {
331
                throw new \Exception('failed to parse list segment');
332
            }
333
334
            $this->parseRange($index, $register, $range, $stepping);
335
        }
336
    }
337
338
    /**
339
     * @param int $index
340
     * @param array $register
341
     * @param array $range
342
     * @param int $stepping
343
     * @throws \Exception
344
     */
345
    private function parseRange($index, array &$register, array $range, $stepping)
346
    {
347
        $this->validateRange($index, $range);
348
349
        // fill matching register
350
        if ($range[0] === $range[1]) {
351
            $register[$index][$range[0]] = true;
352
        } else {
353
            for ($i = self::$boundaries[$index]['min']; $i <= self::$boundaries[$index]['max']; $i++) {
354
                if (($i - $range[0]) % $stepping === 0) {
355
                    if ($range[0] < $range[1]) {
356 View Code Duplication
                        if ($i >= $range[0] && $i <= $range[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...
357
                            $register[$index][$i] = true;
358
                        }
359 View Code Duplication
                    } elseif ($i >= $range[0] || $i <= $range[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...
360
                        $register[$index][$i] = true;
361
                    }
362
                }
363
            }
364
        }
365
    }
366
367
    /**
368
     * Validate range of values
369
     *
370
     * @param int $index
371
     * @param array $range
372
     * @throws \Exception
373
     */
374
    private function validateRange($index, array $range)
375
    {
376
        if (sizeof($range) !== 2) {
377
            throw new \Exception('invalid range notation');
378
        }
379
380
        foreach ($range as $value) {
381
            if (is_numeric($value)) {
382 View Code Duplication
                if (intval($value) < self::$boundaries[$index]['min'] ||
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...
383
                    intval($value) > self::$boundaries[$index]['max']) {
384
                    throw new \Exception('invalid range start or end value');
385
                }
386
            } else {
387
                throw new \Exception('non-numeric range notation');
388
            }
389
        }
390
    }
391
392
    /**
393
     * @param int $index
394
     * @param string $element
395
     * @return int
396
     * @throws \Exception
397
     */
398
    private function parseStepping($index, &$element)
399
    {
400
        $stepping = 1;
401
402
        // parse stepping notation
403
        if (strpos($element, '/') !== false) {
404
            if (sizeof($stepsegments = explode('/', $element)) === 2) {
405
                $element = $stepsegments[0];
406
407
                if (is_numeric($stepsegments[1])) {
408
                    if ($stepsegments[1] > 0 && $stepsegments[1] <= self::$boundaries[$index]['max']) {
409
                        $stepping = intval($stepsegments[1]);
410
                    } else {
411
                        throw new \Exception('stepping value out of allowed range');
412
                    }
413
                } else {
414
                    throw new \Exception('non-numeric stepping notation');
415
                }
416
            } else {
417
                throw new \Exception('invalid stepping notation');
418
            }
419
        }
420
421
        return $stepping;
422
    }
423
}
424