Completed
Push — master ( d33bae...b01b8b )
by René
03:16
created

CronExpression::parseSegment()   A

Complexity

Conditions 4
Paths 3

Size

Total Lines 15

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 4

Importance

Changes 0
Metric Value
dl 0
loc 15
ccs 7
cts 7
cp 1
rs 9.7666
c 0
b 0
f 0
cc 4
nc 3
nop 3
crap 4
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 12
    public function __construct(string $expression = '* * * * *', \DateTimeZone $timeZone = null)
103
    {
104 12
        $this->setExpression($expression);
105 12
        $this->setTimeZone($timeZone);
106 12
    }
107
108
    /**
109
     * Set expression
110
     *
111
     * @param string $expression
112
     * @return self
113
     */
114 12
    public function setExpression(string $expression): self
115
    {
116 12
        $this->expression = trim($expression);
117 12
        $this->register = null;
118
119 12
        return $this;
120
    }
121
122
    /**
123
     * Set time zone
124
     *
125
     * @param \DateTimeZone|null $timeZone
126
     * @return self
127
     */
128 12
    public function setTimeZone(\DateTimeZone $timeZone = null): self
129
    {
130 12
        $this->timeZone = $timeZone;
131 12
        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 11
    public function getNext($start = null)
141
    {
142 11
        $result = false;
143
144 11
        if ($this->isValid()) {
145 11
            if ($start instanceof \DateTime) {
146 1
                $timestamp = $start->getTimestamp();
147 10
            } elseif ((int)$start > 0) {
148 9
                $timestamp = $start;
149
            } else {
150 1
                $timestamp = time();
151
            }
152
153 11
            $now = new \DateTime('now', $this->timeZone);
154 11
            $now->setTimestamp(ceil($timestamp / 60) * 60);
155
156 11
            if ($this->isMatching($now)) {
157 4
                $now->modify('+1 minute');
158
            }
159
160 11
            $pointer = sscanf($now->format('i G j n Y'), '%d %d %d %d %d');
161
162
            do {
163 11
                $current = $this->adjust($now, $pointer);
164 11
            } while ($this->forward($now, $current));
165
166 11
            $result = $now->getTimestamp();
167
        }
168
169 11
        return $result;
170
    }
171
172
    /**
173
     * @param \DateTime $now
174
     * @param array $pointer
175
     * @return array
176
     */
177 11
    private function adjust(\DateTime $now, array &$pointer): array
178
    {
179 11
        $current = sscanf($now->format('i G j n Y w'), '%d %d %d %d %d %d');
180
181
        switch (true) {
182 11 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 6
                $pointer[1] = $current[1];
184 6
                $now->setTime($current[1], 0);
185 6
                break;
186
187 11 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 9
                $pointer[0] = $current[0];
189 9
                $now->setTime($current[1], $current[0]);
190 9
                break;
191
192 11 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 11 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 11 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 11
        return $current;
211
    }
212
213
    /**
214
     * @param \DateTime $now
215
     * @param array $current
216
     * @return bool
217
     */
218 11
    private function forward(\DateTime $now, array $current): bool
219
    {
220 11
        $result = false;
221
222 11
        if (isset($this->register[3][$current[3]]) === false) {
223 1
            $now->modify('+1 month');
224 1
            $result = true;
225 11
        } 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 11
        } elseif (isset($this->register[1][$current[1]]) === false) {
229 5
            $now->modify('+1 hour');
230 5
            $result = true;
231 11
        } elseif (isset($this->register[0][$current[0]]) === false) {
232 8
            $now->modify('+1 minute');
233 8
            $result = true;
234
        }
235
236 11
        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 11
    public function isValid(): bool
245
    {
246 11
        $result = true;
247
248 11
        if ($this->register === null) {
249
            try {
250 11
                $this->register = $this->parse();
251
            } catch (\Exception $e) {
252
                $result = false;
253
            }
254
        }
255
256 11
        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 12
    public function isMatching($now = null): bool
266
    {
267 12
        if (false === ($now instanceof \DateTime)) {
268
            $now = (new \DateTime())->setTimestamp($now === null ? time() : $now);
269
        }
270
271 12
        if ($this->timeZone !== null) {
272 10
            $now->setTimezone($this->timeZone);
273
        }
274
275
        try {
276 12
            $result = $this->match(sscanf($now->format('i G j n w'), '%d %d %d %d %d'));
277
        } catch (\Exception $e) {
278
            $result = false;
279
        }
280
281 12
        return $result;
282
    }
283
284
    /**
285
     * @param array $segments
286
     * @return bool
287
     * @throws \Exception
288
     */
289 12
    private function match(array $segments): bool
290
    {
291 12
        $result = true;
292
293 12
        foreach ($this->parse() as $i => $item) {
294 12
            if (isset($item[(int)$segments[$i]]) === false) {
295 8
                $result = false;
296 12
                break;
297
            }
298
        }
299
300 12
        return $result;
301
    }
302
303
    /**
304
     * Parse whole cron expression
305
     *
306
     * @return array
307
     * @throws \Exception
308
     */
309 12
    private function parse(): array
310
    {
311 12
        $register = [];
312
313 12
        if (sizeof($segments = preg_split('/\s+/', $this->expression)) === 5) {
314 12
            foreach ($segments as $index => $segment) {
315 12
                $this->parseSegment($index, $register, $segment);
316
            }
317
318 12
            if (isset($register[4][7])) {
319 12
                $register[4][0] = true;
320
            }
321
        } else {
322
            throw new \Exception('invalid number of segments');
323
        }
324
325 12
        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 12
    private function parseSegment($index, array &$register, $segment)
337
    {
338 12
        $allowed = [false, false, false, self::$months, self::$weekdays];
339
340
        // month names, weekdays
341 12
        if ($allowed[$index] !== false && isset($allowed[$index][strtolower($segment)])) {
342
            // cannot be used together with lists or ranges
343 2
            $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 12
            foreach (explode(',', $segment) as $element) {
347 12
                $this->parseElement($index, $register, $element);
348
            }
349
        }
350 12
    }
351
352
    /**
353
     * @param int $index
354
     * @param array $register
355
     * @param string $element
356
     * @throws \Exception
357
     */
358 12
    private function parseElement(int $index, array &$register, string $element)
359
    {
360 12
        $stepping = 1;
361
362 12
        if (false !== strpos($element, '/')) {
363 2
            $this->parseStepping($index, $element, $stepping);
364
        }
365
366 12
        if (is_numeric($element)) {
367 9
            $this->validateValue($index, $element);
368
369 9
            if ($stepping !== 1) {
370
                throw new \Exception('invalid combination of value and stepping notation');
371
            }
372
373 9
            $register[$index][intval($element)] = true;
374
        } else {
375 11
            $this->parseRange($index, $register, $element, $stepping);
376
        }
377 12
    }
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 11
    private function parseRange(int $index, array &$register, string $range, int $stepping)
389
    {
390 11
        if ($range === '*') {
391 11
            $range = [self::$boundaries[$index]['min'], self::$boundaries[$index]['max']];
392
        } elseif (strpos($range, '-') !== false) {
393
            $range = $this->validateRange($index, explode('-', $range));
394
        } else {
395
            throw new \Exception('failed to parse list segment');
396
        }
397
398 11
        $this->fillRegister($index, $register, $range, $stepping);
399 11
    }
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 2
    private function parseStepping(int $index, string &$element, int &$stepping)
410
    {
411 2
        $segments = explode('/', $element);
412
413 2
        $this->validateStepping($index, $segments);
414
415 2
        $element = (string)$segments[0];
416 2
        $stepping = (int)$segments[1];
417 2
    }
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
    private function validateRange(int $index, array $range): array
428
    {
429
        if (sizeof($range) !== 2) {
430
            throw new \Exception('invalid range notation');
431
        }
432
433
        foreach ($range as $value) {
434
            $this->validateValue($index, $value);
435
        }
436
437
        return $range;
438
    }
439
    /**
440
     * @param int $index
441
     * @param string $value
442
     * @throws \Exception
443
     */
444 9
    private function validateValue(int $index, string $value)
445
    {
446 9
        if (is_numeric($value)) {
447 9
            if (intval($value) < self::$boundaries[$index]['min'] ||
448 9
                intval($value) > self::$boundaries[$index]['max']) {
449 9
                throw new \Exception('value boundary exceeded');
450
            }
451
        } else {
452
            throw new \Exception('non-integer value');
453
        }
454 9
    }
455
456
    /**
457
     * @param int $index
458
     * @param array $segments
459
     * @throws \Exception
460
     */
461 2
    private function validateStepping(int $index, array $segments)
462
    {
463 2
        if (sizeof($segments) !== 2) {
464
            throw new \Exception('invalid stepping notation');
465
        }
466
467 2
        if ((int)$segments[1] <= 0 || (int)$segments[1] > self::$boundaries[$index]['max']) {
468
            throw new \Exception('stepping out of allowed range');
469
        }
470 2
    }
471
472
    /**
473
     * @param int $index
474
     * @param array $register
475
     * @param array $range
476
     * @param int $stepping
477
     */
478 11
    private function fillRegister(int $index, array &$register, array $range, int $stepping)
479
    {
480 11
        for ($i = self::$boundaries[$index]['min']; $i <= self::$boundaries[$index]['max']; $i++) {
481 11
            if (($i - $range[0]) % $stepping === 0) {
482 11
                if ($range[0] < $range[1]) {
483 11
                    $this->fillRegisterBetweenBoundaries($index, $register, $range, $i);
484
                } else {
485
                    $this->fillRegisterAcrossBoundaries($index, $register, $range, $i);
486
                }
487
            }
488
        }
489 11
    }
490
491
    /**
492
     * @param int $index
493
     * @param array $register
494
     * @param array $range
495
     * @param int $value
496
     */
497
    private function fillRegisterAcrossBoundaries(int $index, array &$register, array $range, int $value)
498
    {
499 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
            $register[$index][$value] = true;
501
        }
502
    }
503
504
    /**
505
     * @param int $index
506
     * @param array $register
507
     * @param array $range
508
     * @param int $value
509
     */
510 11
    private function fillRegisterBetweenBoundaries(int $index, array &$register, array $range, int $value)
511
    {
512 11 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 11
            $register[$index][$value] = true;
514
        }
515 11
    }
516
}
517