Completed
Push — master ( 82b7d1...07269d )
by René
02:59
created

Cron::match()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 13
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
c 1
b 0
f 0
dl 0
loc 13
rs 9.4285
cc 3
eloc 7
nc 3
nop 1
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 (false === ($dtime instanceof \DateTime)) {
161
            $dt = new \DateTime();
162
            $dt->setTimestamp($dtime === null ? time() : $dtime);
163
164
            $dtime = $dt;
165
        }
166
167
        $dtime->setTimezone($this->timeZone);
168
169
        try {
170
            $result = $this->match(sscanf($dtime->format('i G j n w'), '%d %d %d %d %d'));
171
        } catch (\Exception $e) {
172
            $result = false;
173
        }
174
175
        return $result;
176
    }
177
178
    /**
179
     * @param array $segments
180
     * @return bool
181
     * @throws \Exception
182
     */
183
    private function match(array $segments)
184
    {
185
        $result = true;
186
187
        foreach ($this->parse() as $i => $item) {
188
            if (isset($item[(int)$segments[$i]]) === false) {
189
                $result = false;
190
                break;
191
            }
192
        }
193
194
        return $result;
195
    }
196
197
    /**
198
     * Calculate next matching timestamp
199
     *
200
     * @param mixed $dtime \DateTime object, timestamp or null
201
     * @return int|bool next matching timestamp, or false on error
202
     */
203
    public function getNext($dtime = null)
204
    {
205
        $result = false;
206
207
        if ($this->isValid()) {
208
            if ($dtime instanceof \DateTime) {
209
                $timestamp = $dtime->getTimestamp();
210
            } elseif ((int)$dtime > 0) {
211
                $timestamp = $dtime;
212
            } else {
213
                $timestamp = time();
214
            }
215
216
            $dt = new \DateTime('now', $this->timeZone);
217
            $dt->setTimestamp(ceil($timestamp / 60) * 60);
218
219
            list($pday, $pmonth, $pyear, $phour) = sscanf(
220
                $dt->format('j n Y G'),
221
                '%d %d %d %d'
222
            );
223
224
            while ($result === false) {
225
                list($minute, $hour, $day, $month, $year, $weekday) = sscanf(
226
                    $dt->format('i G j n Y w'),
227
                    '%d %d %d %d %d %d'
228
                );
229
230
                if ($pyear !== $year) {
231
                    $dt->setDate($year, 1, 1);
232
                    $dt->setTime(0, 0);
233
                } elseif ($pmonth !== $month) {
234
                    $dt->setDate($year, $month, 1);
235
                    $dt->setTime(0, 0);
236
                } elseif ($pday !== $day) {
237
                    $dt->setTime(0, 0);
238
                } elseif ($phour !== $hour) {
239
                    $dt->setTime($hour, 0);
240
                }
241
242
                list($pday, $pmonth, $pyear, $phour) = [$day, $month, $year, $hour];
243
244
                if (isset($this->register[3][$month]) === false) {
245
                    $dt->modify('+1 month');
246
                    continue;
247
                } elseif (false === (isset($this->register[2][$day]) && isset($this->register[4][$weekday]))) {
248
                    $dt->modify('+1 day');
249
                    continue;
250
                } elseif (isset($this->register[1][$hour]) === false) {
251
                    $dt->modify('+1 hour');
252
                    continue;
253
                } elseif (isset($this->register[0][$minute]) === false) {
254
                    $dt->modify('+1 minute');
255
                    continue;
256
                }
257
258
                $result = $dt->getTimestamp();
259
            }
260
        }
261
262
        return $result;
263
    }
264
265
    /**
266
     * Parse whole cron expression
267
     *
268
     * @return array
269
     * @throws \Exception
270
     */
271
    private function parse()
272
    {
273
        $register = [];
274
275
        if (sizeof($segments = preg_split('/\s+/', $this->expression)) === 5) {
276
            foreach ($segments as $index => $segment) {
277
                $this->parseSegment($index, $register, $segment);
278
            }
279
280
            if (isset($register[4][7])) {
281
                $register[4][0] = true;
282
            }
283
        } else {
284
            throw new \Exception('invalid number of segments');
285
        }
286
287
        return $register;
288
    }
289
290
    /**
291
     * Parse one segment of a cron expression
292
     *
293
     * @param int $index
294
     * @param string $segment
295
     * @param array $register
296
     * @throws \Exception
297
     */
298
    private function parseSegment($index, array &$register, $segment)
299
    {
300
        $strv = [false, false, false, self::$months, self::$weekdays];
301
302
        // month names, weekdays
303
        if ($strv[$index] !== false && isset($strv[$index][strtolower($segment)])) {
304
            // cannot be used with lists or ranges, see crontab(5) man page
305
            $register[$index][$strv[$index][strtolower($segment)]] = true;
306
        } else {
307
            // split up current segment into single elements, e.g. "1,5-7,*/2" => [ "1", "5-7", "*/2" ]
308
            foreach (explode(',', $segment) as $element) {
309
                $this->parseElement($index, $register, $element);
310
            }
311
        }
312
    }
313
314
    /**
315
     * @param int $index
316
     * @param array $register
317
     * @param string $element
318
     * @throws \Exception
319
     */
320
    private function parseElement($index, array &$register, $element)
321
    {
322
        $stepping = 1;
323
324
        if (false !== strpos($element, '/')) {
325
            $this->parseStepping($index, $element, $stepping);
326
        }
327
328
        if (is_numeric($element)) {
329
            $this->validateValue($index, $element);
330
331
            if ($stepping !== 1) {
332
                throw new \Exception('invalid combination of value and stepping notation');
333
            }
334
335
            $register[$index][intval($element)] = true;
336
        } else {
337
            $this->parseRange($index, $register, $element, $stepping);
338
        }
339
    }
340
341
    /**
342
     * Parse range of values, e.g. "5-10"
343
     *
344
     * @param int $index
345
     * @param array $register
346
     * @param string $range
347
     * @param int $stepping
348
     * @throws \Exception
349
     */
350
    private function parseRange($index, array &$register, $range, $stepping)
351
    {
352
        if ($range === '*') {
353
            $range = [self::$boundaries[$index]['min'], self::$boundaries[$index]['max']];
354
        } elseif (strpos($range, '-') !== false) {
355
            $range = $this->validateRange($index, explode('-', $range));
356
        } else {
357
            throw new \Exception('failed to parse list segment');
358
        }
359
360
        $this->fillRegister($index, $register, $range, $stepping);
361
    }
362
363
    /**
364
     * Validate whether a given range of values exceeds allowed value boundaries
365
     *
366
     * @param int $index
367
     * @param array $range
368
     * @return array
369
     * @throws \Exception
370
     */
371
    private function validateRange($index, array $range)
372
    {
373
        if (sizeof($range) !== 2) {
374
            throw new \Exception('invalid range notation');
375
        }
376
377
        foreach ($range as $value) {
378
            $this->validateValue($index, $value);
379
        }
380
381
        return $range;
382
    }
383
384
    /**
385
     * @param int $index
386
     * @param array $register
387
     * @param array $range
388
     * @param int $stepping
389
     */
390
    private function fillRegister($index, array &$register, array $range, $stepping)
391
    {
392
        for ($i = self::$boundaries[$index]['min']; $i <= self::$boundaries[$index]['max']; $i++) {
393
            if (($i - $range[0]) % $stepping === 0) {
394
                if ($range[0] < $range[1]) {
395
                    $this->fillRegisterBetweenBoundaries($index, $register, $range, $i);
396
                } else {
397
                    $this->fillRegisterAcrossBoundaries($index, $register, $range, $i);
398
                }
399
            }
400
        }
401
    }
402
403
    /**
404
     * @param int $index
405
     * @param array $register
406
     * @param array $range
407
     * @param int $value
408
     */
409
    private function fillRegisterAcrossBoundaries($index, array &$register, $range, $value)
410
    {
411 View Code Duplication
        if ($value >= $range[0] || $value <= $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...
412
            $register[$index][$value] = true;
413
        }
414
    }
415
416
    /**
417
     * @param int $index
418
     * @param array $register
419
     * @param array $range
420
     * @param int $value
421
     */
422
    private function fillRegisterBetweenBoundaries($index, array &$register, $range, $value)
423
    {
424 View Code Duplication
        if ($value >= $range[0] && $value <= $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...
425
            $register[$index][$value] = true;
426
        }
427
    }
428
429
    /**
430
     * @param int $index
431
     * @param int $value
432
     * @throws \Exception
433
     */
434
    private function validateValue($index, $value)
435
    {
436
        if (is_numeric($value)) {
437
            if (intval($value) < self::$boundaries[$index]['min'] ||
438
                intval($value) > self::$boundaries[$index]['max']) {
439
                throw new \Exception('value boundary exceeded');
440
            }
441
        } else {
442
            throw new \Exception('non-integer value');
443
        }
444
    }
445
446
    /**
447
     * Parse stepping notation, e.g. "5-10/2" => 2
448
     *
449
     * @param int $index
450
     * @param string $element
451
     * @param int $stepping
452
     * @throws \Exception
453
     */
454
    private function parseStepping($index, &$element, &$stepping)
455
    {
456
        $segments = explode('/', $element);
457
458
        $this->validateStepping($index, $segments);
459
460
        $element = (string)$segments[0];
461
        $stepping = (int)$segments[1];
462
    }
463
464
    /**
465
     * @param int $index
466
     * @param array $segments
467
     * @throws \Exception
468
     */
469
    private function validateStepping($index, array $segments)
470
    {
471
        if (sizeof($segments) !== 2) {
472
            throw new \Exception('invalid stepping notation');
473
        }
474
475
        if ((int)$segments[1] <= 0 || (int)$segments[1] > self::$boundaries[$index]['max']) {
476
            throw new \Exception('stepping out of allowed range');
477
        }
478
    }
479
}
480