Passed
Push — master ( 656624...c1fe57 )
by René
01:19
created

CronExpression   F

Complexity

Total Complexity 61

Size/Duplication

Total Lines 455
Duplicated Lines 0 %

Test Coverage

Coverage 100%

Importance

Changes 0
Metric Value
eloc 180
dl 0
loc 455
ccs 148
cts 148
cp 1
rs 3.52
c 0
b 0
f 0
wmc 61

17 Methods

Rating   Name   Duplication   Size   Complexity  
A adjust() 0 24 6
A getNext() 0 30 6
A isValid() 0 13 3
A isMatching() 0 17 5
A setTimeZone() 0 4 1
A parse() 0 19 5
A match() 0 12 3
A __construct() 0 4 1
A parseSegment() 0 12 4
A forward() 0 19 6
A setExpression() 0 6 1
A validateStepping() 0 8 4
A fillRange() 0 11 3
A parseElement() 0 22 4
A validateRange() 0 8 3
A validateValue() 0 9 4
A parseRange() 0 10 2

How to fix   Complexity   

Complex Class

Complex classes like CronExpression often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use CronExpression, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
namespace Cron;
4
5
use \DateTime;
6
use \DateTimeZone;
7
use \Exception;
8
9
/**
10
 * Cron expression parser and validator
11
 *
12
 * @author René Pollesch
13
 */
14
class CronExpression
15
{
16
    /**
17
     * Weekday name look-up table
18
     */
19
    private const WEEKDAY_NAMES = [
20
        'sun' => 0,
21
        'mon' => 1,
22
        'tue' => 2,
23
        'wed' => 3,
24
        'thu' => 4,
25
        'fri' => 5,
26
        'sat' => 6
27
    ];
28
29
    /**
30
     * Month name look-up table
31
     */
32
    private const MONTH_NAMES = [
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
    private const VALUE_BOUNDARIES = [
51
        0 => [
52
            'min' => 0,
53
            'max' => 59,
54
            'mod' => 1
55
        ],
56
        1 => [
57
            'min' => 0,
58
            'max' => 23,
59
            'mod' => 1
60
        ],
61
        2 => [
62
            'min' => 1,
63
            'max' => 31,
64
            'mod' => 1
65
        ],
66
        3 => [
67
            'min' => 1,
68
            'max' => 12,
69
            'mod' => 1
70
        ],
71
        4 => [
72
            'min' => 0,
73
            'max' => 7,
74
            'mod' => 0
75
        ]
76
    ];
77
78
    /**
79
     * Cron expression
80
     *
81
     * @var string
82
     */
83
    protected $expression;
84
85
    /**
86
     * Time zone
87
     *
88
     * @var DateTimeZone
89
     */
90
    protected $timeZone;
91
92
    /**
93
     * Matching registers
94
     *
95
     * @var array|null
96
     */
97
    protected $registers;
98
99
    /**
100
     * Class constructor sets cron expression property
101
     *
102 93
     * @param string $expression cron expression
103
     * @param DateTimeZone|null $timeZone
104 93
     */
105 93
    public function __construct(string $expression = '* * * * *', DateTimeZone $timeZone = null)
106 93
    {
107
        $this->setExpression($expression);
108
        $this->setTimeZone($timeZone);
109
    }
110
111
    /**
112
     * Set expression
113
     *
114 93
     * @param string $expression
115
     * @return self
116 93
     */
117 93
    public function setExpression(string $expression): self
118
    {
119 93
        $this->expression = trim($expression);
120
        $this->registers = null;
121
122
        return $this;
123
    }
124
125
    /**
126
     * Set time zone
127
     *
128 93
     * @param DateTimeZone|null $timeZone
129
     * @return self
130 93
     */
131 93
    public function setTimeZone(DateTimeZone $timeZone = null): self
132
    {
133
        $this->timeZone = $timeZone;
134
        return $this;
135
    }
136
137
    /**
138
     * Calculate next matching timestamp
139
     *
140
     * @param mixed $start either a \DateTime object, a timestamp or null for current date/time
141 11
     * @return int|bool next matching timestamp, or false on error
142
     * @throws Exception
143 11
     */
144
    public function getNext($start = null)
145 11
    {
146 11
        $result = false;
147 1
148 10
        if ($this->isValid()) {
149 9
            if ($start instanceof DateTime) {
150
                $timestamp = $start->getTimestamp();
151 1
            } elseif ((int)$start > 0) {
152
                $timestamp = $start;
153
            } else {
154 11
                $timestamp = time();
155 11
            }
156
157 11
            $now = new DateTime('now', $this->timeZone);
158 4
            $now->setTimestamp(intval(ceil($timestamp / 60)) * 60);
159
160
            if ($this->isMatching($now)) {
161 11
                $now->modify('+1 minute');
162
            }
163
164 11
            $pointer = sscanf($now->format('i G j n Y'), '%d %d %d %d %d');
165 11
166
            do {
167 11
                $current = $this->adjust($now, $pointer);
168
            } while ($this->forward($now, $current));
169
170 11
            $result = $now->getTimestamp();
171
        }
172
173
        return $result;
174
    }
175
176
    /**
177
     * @param DateTime $now
178 11
     * @param array $pointer
179
     * @return array
180 11
     */
181
    private function adjust(DateTime $now, array &$pointer): array
182
    {
183 11
        $current = sscanf($now->format('i G j n Y w'), '%d %d %d %d %d %d');
184 6
185 6
        if ($pointer[1] !== $current[1]) {
186 6
            $pointer[1] = $current[1];
187
            $now->setTime($current[1], 0);
188 11
        } elseif ($pointer[0] !== $current[0]) {
189 9
            $pointer[0] = $current[0];
190 9
            $now->setTime($current[1], $current[0]);
191 9
        } elseif ($pointer[4] !== $current[4]) {
192
            $pointer[4] = $current[4];
193 11
            $now->setDate($current[4], 1, 1);
194 1
            $now->setTime(0, 0);
195 1
        } elseif ($pointer[3] !== $current[3]) {
196 1
            $pointer[3] = $current[3];
197 1
            $now->setDate($current[4], $current[3], 1);
198
            $now->setTime(0, 0);
199 11
        } elseif ($pointer[2] !== $current[2]) {
200 2
            $pointer[2] = $current[2];
201 2
            $now->setTime(0, 0);
202 2
        }
203 2
204
        return $current;
205 11
    }
206 2
207 2
    /**
208 2
     * @param DateTime $now
209
     * @param array $current
210
     * @return bool
211 11
     */
212
    private function forward(DateTime $now, array $current): bool
0 ignored issues
show
Unused Code introduced by
The method forward() is not used, and could be removed.

This check looks for private methods that have been defined, but are not used inside the class.

Loading history...
213
    {
214
        $result = false;
215
216
        if (isset($this->registers[3][$current[3]]) === false) {
217
            $now->modify('+1 month');
218
            $result = true;
219 11
        } elseif (false === (isset($this->registers[2][$current[2]]) && isset($this->registers[4][$current[5]]))) {
220
            $now->modify('+1 day');
221 11
            $result = true;
222
        } elseif (isset($this->registers[1][$current[1]]) === false) {
223 11
            $now->modify('+1 hour');
224 1
            $result = true;
225 1
        } elseif (isset($this->registers[0][$current[0]]) === false) {
226 11
            $now->modify('+1 minute');
227 2
            $result = true;
228 2
        }
229 11
230 4
        return $result;
231 4
    }
232 11
233 8
    /**
234 8
     * Parse and validate cron expression
235
     *
236
     * @return bool true if expression is valid, or false on error
237 11
     */
238
    public function isValid(): bool
239
    {
240
        $result = true;
241
242
        if ($this->registers === null) {
243
            try {
244
                $this->registers = $this->parse();
245 92
            } catch (Exception $e) {
246
                $result = false;
247 92
            }
248
        }
249 92
250
        return $result;
251 92
    }
252 27
253 27
    /**
254
     * Match current or given date/time against cron expression
255
     *
256
     * @param mixed $now \DateTime object, timestamp or null
257 92
     * @return bool
258
     * @throws Exception
259
     */
260
    public function isMatching($now = null): bool
261
    {
262
        if (false === ($now instanceof DateTime)) {
263
            $now = (new DateTime())->setTimestamp($now === null ? time() : $now);
264
        }
265
266
        if ($this->timeZone !== null) {
267 93
            $now->setTimezone($this->timeZone);
268
        }
269 93
270 81
        try {
271
            $result = $this->match(sscanf($now->format('i G j n w'), '%d %d %d %d %d'));
272
        } catch (Exception $e) {
273 93
            $result = false;
274 91
        }
275
276
        return $result;
277
    }
278 93
279 27
    /**
280 27
     * @param array $segments
281
     * @return bool
282
     * @throws Exception
283 93
     */
284
    private function match(array $segments): bool
285
    {
286
        $result = true;
287
288
        foreach ($this->parse() as $i => $item) {
289
            if (isset($item[(int)$segments[$i]]) === false) {
290
                $result = false;
291 93
                break;
292
            }
293 93
        }
294
295 93
        return $result;
296 66
    }
297 32
298 66
    /**
299
     * Parse whole cron expression
300
     *
301
     * @return array
302 66
     * @throws Exception
303
     */
304
    private function parse(): array
305
    {
306
        $segments = preg_split('/\s+/', $this->expression);
307
308
        if (is_array($segments) && sizeof($segments) === 5) {
309
            $registers = array_fill(0, 5, []);
310
311 93
            foreach ($segments as $index => $segment) {
312
                $this->parseSegment($registers[$index], $index, $segment);
313 93
            }
314
315 93
            if (isset($registers[4][7])) {
316 89
                $registers[4][0] = true;
317 89
            }
318
319
            return $registers;
320 66
        }
321 66
322
        throw new Exception('invalid number of segments');
323
    }
324 4
325
    /**
326
     * Parse one segment of a cron expression
327 66
     *
328
     * @param array $register
329
     * @param int $index
330
     * @param string $segment
331
     * @throws Exception
332
     */
333
    private function parseSegment(array &$register, $index, $segment)
334
    {
335
        $allowed = [false, false, false, self::MONTH_NAMES, self::WEEKDAY_NAMES];
336
337
        // month names, weekdays
338 89
        if ($allowed[$index] !== false && isset($allowed[$index][strtolower($segment)])) {
339
            // cannot be used together with lists or ranges
340 89
            $register[$allowed[$index][strtolower($segment)]] = true;
341
        } else {
342
            // split up current segment into single elements, e.g. "1,5-7,*/2" => [ "1", "5-7", "*/2" ]
343 89
            foreach (explode(',', $segment) as $element) {
344
                $this->parseElement($register, $index, $element);
345 5
            }
346
        }
347
    }
348 89
349 89
    /**
350
     * @param array $register
351
     * @param int $index
352 77
     * @param string $element
353
     * @throws Exception
354
     */
355
    private function parseElement(array &$register, int $index, string $element)
356
    {
357
        $step = 1;
358
        $segments = explode('/', $element);
359
360 89
        if (sizeof($segments) > 1) {
361
            $this->validateStepping($segments, $index);
362 89
363
            $element = (string)$segments[0];
364 89
            $step = (int)$segments[1];
365 42
        }
366
367
        if (is_numeric($element)) {
368 86
            $this->validateValue($element, $index);
369 35
370
            if ($step !== 1) {
371 29
                throw new Exception('invalid combination of value and stepping notation');
372 1
            }
373
374
            $register[intval($element)] = true;
375 28
        } else {
376
            $this->parseRange($register, $index, $element, $step);
377 82
        }
378
    }
379 78
380
    /**
381
     * Parse range of values, e.g. "5-10"
382
     *
383
     * @param array $register
384
     * @param int $index
385
     * @param string $range
386
     * @param int $stepping
387
     * @throws Exception
388
     */
389
    private function parseRange(array &$register, int $index, string $range, int $stepping)
390 82
    {
391
        if ($range === '*') {
392 82
            $range = [self::VALUE_BOUNDARIES[$index]['min'], self::VALUE_BOUNDARIES[$index]['max']];
393 75
        } else {
394 51
            $range = explode('-', $range);
395 44
        }
396
397 8
        $this->validateRange($range, $index);
398
        $this->fillRange($register, $index, $range, $stepping);
399
    }
400 77
401 77
    /**
402
     * Validate whether a given range of values exceeds allowed value boundaries
403
     *
404
     * @param array $range
405
     * @param int $index
406
     * @throws Exception
407
     */
408
    private function validateRange(array $range, int $index)
409
    {
410
        if (sizeof($range) !== 2) {
411 42
            throw new Exception('invalid range notation');
412
        }
413 42
414
        foreach ($range as $value) {
415 42
            $this->validateValue($value, $index);
416
        }
417 38
    }
418 38
419 38
    /**
420
     * @param string $value
421
     * @param int $index
422
     * @throws Exception
423
     */
424
    private function validateValue(string $value, int $index)
425
    {
426
        if (is_numeric($value)) {
427
            if (intval($value) < self::VALUE_BOUNDARIES[$index]['min'] ||
428
                intval($value) > self::VALUE_BOUNDARIES[$index]['max']) {
429 44
                throw new Exception('value boundary exceeded');
430
            }
431 44
        } else {
432 1
            throw new Exception('non-integer value');
433
        }
434
    }
435 44
436 44
    /**
437
     * @param array $segments
438
     * @param int $index
439 41
     * @throws Exception
440
     */
441
    private function validateStepping(array $segments, int $index)
442
    {
443
        if (sizeof($segments) !== 2) {
444
            throw new Exception('invalid stepping notation');
445
        }
446 68
447
        if ((int)$segments[1] < 1 || (int)$segments[1] > self::VALUE_BOUNDARIES[$index]['max']) {
448 68
            throw new Exception('stepping out of allowed range');
449 68
        }
450 68
    }
451 68
452
    /**
453
     * @param array $register
454 1
     * @param int $index
455
     * @param array $range
456 62
     * @param int $stepping
457
     */
458
    private function fillRange(array &$register, int $index, array $range, int $stepping)
459
    {
460
        $boundary = self::VALUE_BOUNDARIES[$index]['max'] + self::VALUE_BOUNDARIES[$index]['mod'];
461
        $length = $range[1] - $range[0];
462
463 42
        if ($range[0] > $range[1]) {
464
            $length += $boundary;
465 42
        }
466 1
467
        for ($i = 0; $i <= $length; $i += $stepping) {
468
            $register[($range[0] + $i) % $boundary] = true;
469 41
        }
470 3
    }
471
}
472