Passed
Push — master ( 57920c...4133c2 )
by René
02:59
created

Cron::forward()   B

Complexity

Conditions 6
Paths 5

Size

Total Lines 20
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Importance

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