Completed
Push — master ( 645e89...fc874e )
by René
02:26
created

Cron::parseValue()   B

Complexity

Conditions 7
Paths 4

Size

Total Lines 13
Code Lines 7

Duplication

Lines 6
Ratio 46.15 %

Importance

Changes 3
Bugs 0 Features 0
Metric Value
c 3
b 0
f 0
dl 6
loc 13
rs 8.2222
cc 7
eloc 7
nc 4
nop 4

1 Method

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