Completed
Push — master ( 07269d...57920c )
by René
03:00
created

Cron::forward()   D

Complexity

Conditions 10
Paths 25

Size

Total Lines 39
Code Lines 29

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
c 1
b 0
f 0
dl 0
loc 39
rs 4.8196
cc 10
eloc 29
nc 25
nop 2

How to fix   Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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
            while ($this->forward($dt, $pointer)) {
0 ignored issues
show
Unused Code introduced by
This while loop is empty and can be removed.

This check looks for while loops that have no statements or where all statements have been commented out. This may be the result of changes for debugging or the code may simply be obsolete.

Consider removing the loop.

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