Completed
Push — master ( b88652...65c666 )
by René
02:52
created

Cron::validateStepping()   A

Complexity

Conditions 4
Paths 3

Size

Total Lines 10
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
c 2
b 0
f 0
dl 0
loc 10
rs 9.2
cc 4
eloc 5
nc 3
nop 2
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
            $register[4][0] = isset($register[4][7]);
274
        } else {
275
            throw new \Exception('invalid number of segments');
276
        }
277
278
        return $register;
279
    }
280
281
    /**
282
     * Parse one segment of a cron expression
283
     *
284
     * @param int $index
285
     * @param string $segment
286
     * @param array $register
287
     * @throws \Exception
288
     */
289
    private function parseSegment($index, array &$register, $segment)
290
    {
291
        $strv = [false, false, false, self::$months, self::$weekdays];
292
293
        // month names, weekdays
294
        if ($strv[$index] !== false && isset($strv[$index][strtolower($segment)])) {
295
            // cannot be used with lists or ranges, see crontab(5) man page
296
            $register[$index][$strv[$index][strtolower($segment)]] = true;
297
        } else {
298
            // split up current segment into single elements, e.g. "1,5-7,*/2" => [ "1", "5-7", "*/2" ]
299
            foreach (explode(',', $segment) as $element) {
300
                $this->parseElement($index, $register, $element);
301
            }
302
        }
303
    }
304
305
    /**
306
     * @param int $index
307
     * @param array $register
308
     * @param string $element
309
     * @throws \Exception
310
     */
311
    private function parseElement($index, array &$register, $element)
312
    {
313
        $stepping = 1;
314
315
        if (false !== strpos($element, '/')) {
316
            $this->parseStepping($index, $element, $stepping);
317
        }
318
319
        if (is_numeric($element)) {
320
            $this->validateValue($index, $element);
321
322
            if ($stepping !== 1) {
323
                throw new \Exception('invalid combination of value and stepping notation');
324
            }
325
326
            $register[$index][intval($element)] = true;
327
        } else {
328
            $this->parseRange($index, $register, $element, $stepping);
329
        }
330
    }
331
332
    /**
333
     * Parse range of values, e.g. "5-10"
334
     *
335
     * @param int $index
336
     * @param array $register
337
     * @param string $range
338
     * @param int $stepping
339
     * @throws \Exception
340
     */
341
    private function parseRange($index, array &$register, $range, $stepping)
342
    {
343
        if ($range === '*') {
344
            $range = [self::$boundaries[$index]['min'], self::$boundaries[$index]['max']];
345
        } elseif (strpos($range, '-') !== false) {
346
            $range = $this->validateRange($index, explode('-', $range));
347
        } else {
348
            throw new \Exception('failed to parse list segment');
349
        }
350
351
        $this->parseValue($index, $register, $range, $stepping);
352
    }
353
354
    /**
355
     * Validate whether a given range of values exceeds allowed value boundaries
356
     *
357
     * @param int $index
358
     * @param array $range
359
     * @return array
360
     * @throws \Exception
361
     */
362
    private function validateRange($index, array $range)
363
    {
364
        if (sizeof($range) !== 2) {
365
            throw new \Exception('invalid range notation');
366
        }
367
368
        foreach ($range as $value) {
369
            $this->validateValue($index, $value);
370
        }
371
372
        return $range;
373
    }
374
375
    /**
376
     * @param int $index
377
     * @param array $register
378
     * @param array $range
379
     * @param int $stepping
380
     */
381
    private function parseValue($index, array &$register, array $range, $stepping)
382
    {
383
        for ($i = self::$boundaries[$index]['min']; $i <= self::$boundaries[$index]['max']; $i++) {
384
            if (($i - $range[0]) % $stepping === 0) {
385
                if ($range[0] < $range[1]) {
386 View Code Duplication
                    if ($i >= $range[0] && $i <= $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...
387
                        $register[$index][$i] = true;
388
                    }
389 View Code Duplication
                } elseif ($i >= $range[0] || $i <= $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...
390
                    $register[$index][$i] = true;
391
                }
392
            }
393
        }
394
    }
395
396
    /**
397
     * @param int $index
398
     * @param int $value
399
     * @throws \Exception
400
     */
401
    private function validateValue($index, $value)
402
    {
403
        if (is_numeric($value)) {
404
            if (intval($value) < self::$boundaries[$index]['min'] ||
405
                intval($value) > self::$boundaries[$index]['max']) {
406
                throw new \Exception('value boundary exceeded');
407
            }
408
        } else {
409
            throw new \Exception('non-integer value');
410
        }
411
    }
412
413
    /**
414
     * Parse stepping notation, e.g. "5-10/2" => 2
415
     *
416
     * @param int $index
417
     * @param string $element
418
     * @param int $stepping
419
     * @throws \Exception
420
     */
421
    private function parseStepping($index, &$element, &$stepping)
422
    {
423
        $segments = explode('/', $element);
424
425
        $this->validateStepping($index, $segments);
426
427
        $element = (string)$segments[0];
428
        $stepping = (int)$segments[1];
429
    }
430
431
    /**
432
     * @param int $index
433
     * @param array $segments
434
     * @throws \Exception
435
     */
436
    private function validateStepping($index, array $segments)
437
    {
438
        if (sizeof($segments) !== 2) {
439
            throw new \Exception('invalid stepping notation');
440
        }
441
442
        if ((int)$segments[1] <= 0 || (int)$segments[1] > self::$boundaries[$index]['max']) {
443
            throw new \Exception('stepping out of allowed range');
444
        }
445
    }
446
}
447