Completed
Push — master ( 7b675a...b02c07 )
by René
03:19
created

CronExpression::isMatching()   B

Complexity

Conditions 5
Paths 8

Size

Total Lines 18
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 9
CRAP Score 5

Importance

Changes 0
Metric Value
dl 0
loc 18
ccs 9
cts 9
cp 1
rs 8.8571
c 0
b 0
f 0
cc 5
eloc 10
nc 8
nop 1
crap 5
1
<?php
2
3
namespace Cron;
4
5
/**
6
 * Cron expression parser and validator
7
 *
8
 * @author René Pollesch
9
 */
10
class CronExpression
11
{
12
    /**
13
     * Weekday look-up table
14
     *
15
     * @var array
16
     */
17
    protected static $weekdays = [
18
        'sun' => 0,
19
        'mon' => 1,
20
        'tue' => 2,
21
        'wed' => 3,
22
        'thu' => 4,
23
        'fri' => 5,
24
        'sat' => 6
25
    ];
26
27
    /**
28
     * Month name look-up table
29
     *
30
     * @var array
31
     */
32
    protected static $months = [
33
        'jan' => 1,
34
        'feb' => 2,
35
        'mar' => 3,
36
        'apr' => 4,
37
        'may' => 5,
38
        'jun' => 6,
39
        'jul' => 7,
40
        'aug' => 8,
41
        'sep' => 9,
42
        'oct' => 10,
43
        'nov' => 11,
44
        'dec' => 12
45
    ];
46
47
    /**
48
     * Value boundaries
49
     *
50
     * @var array
51
     */
52
    protected static $boundaries = [
53
        0 => [
54
            'min' => 0,
55
            'max' => 59
56
        ],
57
        1 => [
58
            'min' => 0,
59
            'max' => 23
60
        ],
61
        2 => [
62
            'min' => 1,
63
            'max' => 31
64
        ],
65
        3 => [
66
            'min' => 1,
67
            'max' => 12
68
        ],
69
        4 => [
70
            'min' => 0,
71
            'max' => 7
72
        ]
73
    ];
74
75
    /**
76
     * Cron expression
77
     *
78
     * @var string
79
     */
80
    protected $expression;
81
82
    /**
83
     * Time zone
84
     *
85
     * @var \DateTimeZone
86
     */
87
    protected $timeZone;
88
89
    /**
90
     * Matching register
91
     *
92
     * @var array|null
93
     */
94
    protected $register;
95
96
    /**
97
     * Class constructor sets cron expression property
98
     *
99
     * @param string $expression cron expression
100
     * @param \DateTimeZone|null $timeZone
101
     */
102 90
    public function __construct(string $expression = '* * * * *', \DateTimeZone $timeZone = null)
103
    {
104 90
        $this->setExpression($expression);
105 90
        $this->setTimeZone($timeZone);
106 90
    }
107
108
    /**
109
     * Set expression
110
     *
111
     * @param string $expression
112
     * @return self
113
     */
114 90
    public function setExpression(string $expression): self
115
    {
116 90
        $this->expression = trim($expression);
117 90
        $this->register = null;
118
119 90
        return $this;
120
    }
121
122
    /**
123
     * Set time zone
124
     *
125
     * @param \DateTimeZone|null $timeZone
126
     * @return self
127
     */
128 90
    public function setTimeZone(\DateTimeZone $timeZone = null): self
129
    {
130 90
        $this->timeZone = $timeZone;
131 90
        return $this;
132
    }
133
134
    /**
135
     * Calculate next matching timestamp
136
     *
137
     * @param mixed $start either a \DateTime object, a timestamp or null for current date/time
138
     * @return int|bool next matching timestamp, or false on error
139
     */
140 8
    public function getNext($start = null)
141
    {
142 8
        $result = false;
143
144 8
        if ($this->isValid()) {
145 8
            if ($start instanceof \DateTime) {
146 1
                $timestamp = $start->getTimestamp();
147 7
            } elseif ((int)$start > 0) {
148 6
                $timestamp = $start;
149
            } else {
150 1
                $timestamp = time();
151
            }
152
153 8
            $now = new \DateTime('now', $this->timeZone);
154 8
            $now->setTimestamp(ceil($timestamp / 60) * 60);
155
156 8
            if ($this->isMatching($now)) {
157 2
                $now->modify('+1 minute');
158
            }
159
160 8
            $pointer = sscanf($now->format('i G j n Y'), '%d %d %d %d %d');
161
162
            do {
163 8
                $current = $this->adjust($now, $pointer);
164 8
            } while ($this->forward($now, $current));
165
166 8
            $result = $now->getTimestamp();
167
        }
168
169 8
        return $result;
170
    }
171
172
    /**
173
     * @param \DateTime $now
174
     * @param array $pointer
175
     * @return array
176
     */
177 8
    private function adjust(\DateTime $now, array &$pointer): array
178
    {
179 8
        $current = sscanf($now->format('i G j n Y w'), '%d %d %d %d %d %d');
180
181
        switch (true) {
182 8 View Code Duplication
            case ($pointer[1] !== $current[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...
183 5
                $pointer[1] = $current[1];
184 5
                $now->setTime($current[1], 0);
185 5
                break;
186
187 8 View Code Duplication
            case ($pointer[0] !== $current[0]):
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...
188 7
                $pointer[0] = $current[0];
189 7
                $now->setTime($current[1], $current[0]);
190 7
                break;
191
192 8 View Code Duplication
            case ($pointer[4] !== $current[4]):
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...
193 1
                $pointer[4] = $current[4];
194 1
                $now->setDate($current[4], 1, 1);
195 1
                $now->setTime(0, 0);
196 1
                break;
197
198 8 View Code Duplication
            case ($pointer[3] !== $current[3]):
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...
199 2
                $pointer[3] = $current[3];
200 2
                $now->setDate($current[4], $current[3], 1);
201 2
                $now->setTime(0, 0);
202 2
                break;
203
204 8 View Code Duplication
            case ($pointer[2] !== $current[2]):
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...
205 2
                $pointer[2] = $current[2];
206 2
                $now->setTime(0, 0);
207 2
                break;
208
        }
209
210 8
        return $current;
211
    }
212
213
    /**
214
     * @param \DateTime $now
215
     * @param array $current
216
     * @return bool
217
     */
218 8
    private function forward(\DateTime $now, array $current): bool
219
    {
220 8
        $result = false;
221
222 8
        if (isset($this->register[3][$current[3]]) === false) {
223 1
            $now->modify('+1 month');
224 1
            $result = true;
225 8
        } elseif (false === (isset($this->register[2][$current[2]]) && isset($this->register[4][$current[5]]))) {
226 2
            $now->modify('+1 day');
227 2
            $result = true;
228 8
        } elseif (isset($this->register[1][$current[1]]) === false) {
229 4
            $now->modify('+1 hour');
230 4
            $result = true;
231 8
        } elseif (isset($this->register[0][$current[0]]) === false) {
232 6
            $now->modify('+1 minute');
233 6
            $result = true;
234
        }
235
236 8
        return $result;
237
    }
238
239
    /**
240
     * Parse and validate cron expression
241
     *
242
     * @return bool true if expression is valid, or false on error
243
     */
244 89
    public function isValid(): bool
245
    {
246 89
        $result = true;
247
248 89
        if ($this->register === null) {
249
            try {
250 89
                $this->register = $this->parse();
251 27
            } catch (\Exception $e) {
252 27
                $result = false;
253
            }
254
        }
255
256 89
        return $result;
257
    }
258
259
    /**
260
     * Match current or given date/time against cron expression
261
     *
262
     * @param mixed $now \DateTime object, timestamp or null
263
     * @return bool
264
     */
265 90
    public function isMatching($now = null): bool
266
    {
267 90
        if (false === ($now instanceof \DateTime)) {
268 81
            $now = (new \DateTime())->setTimestamp($now === null ? time() : $now);
269
        }
270
271 90
        if ($this->timeZone !== null) {
272 88
            $now->setTimezone($this->timeZone);
273
        }
274
275
        try {
276 90
            $result = $this->match(sscanf($now->format('i G j n w'), '%d %d %d %d %d'));
277 27
        } catch (\Exception $e) {
278 27
            $result = false;
279
        }
280
281 90
        return $result;
282
    }
283
284
    /**
285
     * @param array $segments
286
     * @return bool
287
     * @throws \Exception
288
     */
289 90
    private function match(array $segments): bool
290
    {
291 90
        $result = true;
292
293 90
        foreach ($this->parse() as $i => $item) {
294 63
            if (isset($item[(int)$segments[$i]]) === false) {
295 31
                $result = false;
296 63
                break;
297
            }
298
        }
299
300 63
        return $result;
301
    }
302
303
    /**
304
     * Parse whole cron expression
305
     *
306
     * @return array
307
     * @throws \Exception
308
     */
309 90
    private function parse(): array
310
    {
311 90
        $register = [];
312
313 90
        if (sizeof($segments = preg_split('/\s+/', $this->expression)) === 5) {
314 86
            foreach ($segments as $index => $segment) {
315 86
                $this->parseSegment($index, $register, $segment);
316
            }
317
318 63
            if (isset($register[4][7])) {
319 63
                $register[4][0] = true;
320
            }
321
        } else {
322 4
            throw new \Exception('invalid number of segments');
323
        }
324
325 63
        return $register;
326
    }
327
328
    /**
329
     * Parse one segment of a cron expression
330
     *
331
     * @param int $index
332
     * @param string $segment
333
     * @param array $register
334
     * @throws \Exception
335
     */
336 86
    private function parseSegment($index, array &$register, $segment)
337
    {
338 86
        $allowed = [false, false, false, self::$months, self::$weekdays];
339
340
        // month names, weekdays
341 86
        if ($allowed[$index] !== false && isset($allowed[$index][strtolower($segment)])) {
342
            // cannot be used together with lists or ranges
343 5
            $register[$index][$allowed[$index][strtolower($segment)]] = true;
344
        } else {
345
            // split up current segment into single elements, e.g. "1,5-7,*/2" => [ "1", "5-7", "*/2" ]
346 86
            foreach (explode(',', $segment) as $element) {
347 86
                $this->parseElement($index, $register, $element);
348
            }
349
        }
350 74
    }
351
352
    /**
353
     * @param int $index
354
     * @param array $register
355
     * @param string $element
356
     * @throws \Exception
357
     */
358 86
    private function parseElement(int $index, array &$register, string $element)
359
    {
360 86
        $stepping = 1;
361
362 86
        if (false !== strpos($element, '/')) {
363 42
            $this->parseStepping($index, $element, $stepping);
364
        }
365
366 83
        if (is_numeric($element)) {
367 32
            $this->validateValue($index, $element);
368
369 26
            if ($stepping !== 1) {
370 1
                throw new \Exception('invalid combination of value and stepping notation');
371
            }
372
373 25
            $register[$index][intval($element)] = true;
374
        } else {
375 79
            $this->parseRange($index, $register, $element, $stepping);
376
        }
377 75
    }
378
379
    /**
380
     * Parse range of values, e.g. "5-10"
381
     *
382
     * @param int $index
383
     * @param array $register
384
     * @param string $range
385
     * @param int $stepping
386
     * @throws \Exception
387
     */
388 79
    private function parseRange(int $index, array &$register, string $range, int $stepping)
389
    {
390 79
        if ($range === '*') {
391 72
            $range = [self::$boundaries[$index]['min'], self::$boundaries[$index]['max']];
392 51
        } elseif (strpos($range, '-') !== false) {
393 44
            $range = $this->validateRange($index, explode('-', $range));
394
        } else {
395 8
            throw new \Exception('failed to parse list segment');
396
        }
397
398 74
        $this->fillRegister($index, $register, $range, $stepping);
399 74
    }
400
401
    /**
402
     * Parse stepping notation, e.g. "5-10/2" => 2
403
     *
404
     * @param int $index
405
     * @param string $element
406
     * @param int $stepping
407
     * @throws \Exception
408
     */
409 42
    private function parseStepping(int $index, string &$element, int &$stepping)
410
    {
411 42
        $segments = explode('/', $element);
412
413 42
        $this->validateStepping($index, $segments);
414
415 38
        $element = (string)$segments[0];
416 38
        $stepping = (int)$segments[1];
417 38
    }
418
419
    /**
420
     * Validate whether a given range of values exceeds allowed value boundaries
421
     *
422
     * @param int $index
423
     * @param array $range
424
     * @return array
425
     * @throws \Exception
426
     */
427 44
    private function validateRange(int $index, array $range): array
428
    {
429 44
        if (sizeof($range) !== 2) {
430 1
            throw new \Exception('invalid range notation');
431
        }
432
433 44
        foreach ($range as $value) {
434 44
            $this->validateValue($index, $value);
435
        }
436
437 41
        return $range;
438
    }
439
    /**
440
     * @param int $index
441
     * @param string $value
442
     * @throws \Exception
443
     */
444 65
    private function validateValue(int $index, string $value)
445
    {
446 65
        if (is_numeric($value)) {
447 65
            if (intval($value) < self::$boundaries[$index]['min'] ||
448 65
                intval($value) > self::$boundaries[$index]['max']) {
449 65
                throw new \Exception('value boundary exceeded');
450
            }
451
        } else {
452 1
            throw new \Exception('non-integer value');
453
        }
454 59
    }
455
456
    /**
457
     * @param int $index
458
     * @param array $segments
459
     * @throws \Exception
460
     */
461 42
    private function validateStepping(int $index, array $segments)
462
    {
463 42
        if (sizeof($segments) !== 2) {
464 1
            throw new \Exception('invalid stepping notation');
465
        }
466
467 41
        if ((int)$segments[1] <= 0 || (int)$segments[1] > self::$boundaries[$index]['max']) {
468 3
            throw new \Exception('stepping out of allowed range');
469
        }
470 38
    }
471
472
    /**
473
     * @param int $index
474
     * @param array $register
475
     * @param array $range
476
     * @param int $stepping
477
     */
478 74
    private function fillRegister(int $index, array &$register, array $range, int $stepping)
479
    {
480 74
        for ($i = self::$boundaries[$index]['min']; $i <= self::$boundaries[$index]['max']; $i++) {
481 74
            if (($i - $range[0]) % $stepping === 0) {
482 74
                if ($range[0] < $range[1]) {
483 73
                    $this->fillRegisterBetweenBoundaries($index, $register, $range, $i);
484
                } else {
485 29
                    $this->fillRegisterAcrossBoundaries($index, $register, $range, $i);
486
                }
487
            }
488
        }
489 74
    }
490
491
    /**
492
     * @param int $index
493
     * @param array $register
494
     * @param array $range
495
     * @param int $value
496
     */
497 29
    private function fillRegisterAcrossBoundaries(int $index, array &$register, array $range, int $value)
498
    {
499 29 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...
500 29
            $register[$index][$value] = true;
501
        }
502 29
    }
503
504
    /**
505
     * @param int $index
506
     * @param array $register
507
     * @param array $range
508
     * @param int $value
509
     */
510 73
    private function fillRegisterBetweenBoundaries(int $index, array &$register, array $range, int $value)
511
    {
512 73 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...
513 73
            $register[$index][$value] = true;
514
        }
515 73
    }
516
}
517