Passed
Push — master ( 0bb519...36da30 )
by René
01:16
created

CronExpression::validateRange()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 8
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 3

Importance

Changes 0
Metric Value
cc 3
eloc 4
nc 3
nop 2
dl 0
loc 8
ccs 2
cts 2
cp 1
crap 3
rs 10
c 0
b 0
f 0
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|null
89
     */
90
    protected $timeZone = null;
91
92
    /**
93
     * Matching registers
94
     *
95
     * @var array|null
96
     */
97
    protected $registers = null;
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->expression = trim($expression);
108
        $this->timeZone = $timeZone;
109
    }
110
111
    /**
112
     * Calculate next matching timestamp
113
     *
114 93
     * @param mixed $start either a \DateTime object, a timestamp or null for current date/time
115
     * @return int|bool next matching timestamp, or false on error
116 93
     * @throws Exception
117 93
     */
118
    public function getNext($start = null)
119 93
    {
120
        $result = false;
121
122
        if ($this->isValid()) {
123
            if ($start instanceof DateTime) {
124
                $timestamp = $start->getTimestamp();
125
            } elseif ((int)$start > 0) {
126
                $timestamp = $start;
127
            } else {
128 93
                $timestamp = time();
129
            }
130 93
131 93
            $now = new DateTime('now', $this->timeZone);
132
            $now->setTimestamp(intval(ceil($timestamp / 60)) * 60);
133
134
            if ($this->isMatching($now)) {
135
                $now->modify('+1 minute');
136
            }
137
138
            $pointer = sscanf($now->format('i G j n Y'), '%d %d %d %d %d');
139
140
            do {
141 11
                $current = $this->adjust($now, $pointer);
142
            } while ($this->forward($now, $current));
143 11
144
            $result = $now->getTimestamp();
145 11
        }
146 11
147 1
        return $result;
148 10
    }
149 9
150
    /**
151 1
     * @param DateTime $now
152
     * @param array $pointer
153
     * @return array
154 11
     */
155 11
    private function adjust(DateTime $now, array &$pointer): array
156
    {
157 11
        $current = sscanf($now->format('i G j n Y w'), '%d %d %d %d %d %d');
158 4
159
        if ($pointer[1] !== $current[1]) {
160
            $pointer[1] = $current[1];
161 11
            $now->setTime($current[1], 0);
162
        } elseif ($pointer[0] !== $current[0]) {
163
            $pointer[0] = $current[0];
164 11
            $now->setTime($current[1], $current[0]);
165 11
        } elseif ($pointer[4] !== $current[4]) {
166
            $pointer[4] = $current[4];
167 11
            $now->setDate($current[4], 1, 1);
168
            $now->setTime(0, 0);
169
        } elseif ($pointer[3] !== $current[3]) {
170 11
            $pointer[3] = $current[3];
171
            $now->setDate($current[4], $current[3], 1);
172
            $now->setTime(0, 0);
173
        } elseif ($pointer[2] !== $current[2]) {
174
            $pointer[2] = $current[2];
175
            $now->setTime(0, 0);
176
        }
177
178 11
        return $current;
179
    }
180 11
181
    /**
182
     * @param DateTime $now
183 11
     * @param array $current
184 6
     * @return bool
185 6
     */
186 6
    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...
187
    {
188 11
        $result = false;
189 9
190 9
        if (isset($this->registers[3][$current[3]]) === false) {
191 9
            $now->modify('+1 month');
192
            $result = true;
193 11
        } elseif (false === (isset($this->registers[2][$current[2]]) && isset($this->registers[4][$current[5]]))) {
194 1
            $now->modify('+1 day');
195 1
            $result = true;
196 1
        } elseif (isset($this->registers[1][$current[1]]) === false) {
197 1
            $now->modify('+1 hour');
198
            $result = true;
199 11
        } elseif (isset($this->registers[0][$current[0]]) === false) {
200 2
            $now->modify('+1 minute');
201 2
            $result = true;
202 2
        }
203 2
204
        return $result;
205 11
    }
206 2
207 2
    /**
208 2
     * Parse and validate cron expression
209
     *
210
     * @return bool true if expression is valid, or false on error
211 11
     */
212
    public function isValid(): bool
213
    {
214
        $result = true;
215
216
        if ($this->registers === null) {
217
            try {
218
                $this->registers = $this->parse();
219 11
            } catch (Exception $e) {
220
                $result = false;
221 11
            }
222
        }
223 11
224 1
        return $result;
225 1
    }
226 11
227 2
    /**
228 2
     * Match current or given date/time against cron expression
229 11
     *
230 4
     * @param mixed $now \DateTime object, timestamp or null
231 4
     * @return bool
232 11
     * @throws Exception
233 8
     */
234 8
    public function isMatching($now = null): bool
235
    {
236
        if (false === ($now instanceof DateTime)) {
237 11
            $now = (new DateTime())->setTimestamp($now === null ? time() : $now);
238
        }
239
240
        if ($this->timeZone !== null) {
241
            $now->setTimezone($this->timeZone);
242
        }
243
244
        try {
245 92
            $result = $this->match(sscanf($now->format('i G j n w'), '%d %d %d %d %d'));
246
        } catch (Exception $e) {
247 92
            $result = false;
248
        }
249 92
250
        return $result;
251 92
    }
252 27
253 27
    /**
254
     * @param array $segments
255
     * @return bool
256
     * @throws Exception
257 92
     */
258
    private function match(array $segments): bool
259
    {
260
        $result = true;
261
262
        foreach ($this->parse() as $i => $item) {
263
            if (isset($item[(int)$segments[$i]]) === false) {
264
                $result = false;
265
                break;
266
            }
267 93
        }
268
269 93
        return $result;
270 81
    }
271
272
    /**
273 93
     * Parse whole cron expression
274 91
     *
275
     * @return array
276
     * @throws Exception
277
     */
278 93
    private function parse(): array
279 27
    {
280 27
        $segments = preg_split('/\s+/', $this->expression);
281
282
        if (is_array($segments) && sizeof($segments) === 5) {
283 93
            $registers = array_fill(0, 5, []);
284
285
            foreach ($segments as $index => $segment) {
286
                $this->parseSegment($registers[$index], $index, $segment);
287
            }
288
289
            if (isset($registers[4][7])) {
290
                $registers[4][0] = true;
291 93
            }
292
293 93
            return $registers;
294
        }
295 93
296 66
        throw new Exception('invalid number of segments');
297 32
    }
298 66
299
    /**
300
     * Parse one segment of a cron expression
301
     *
302 66
     * @param array $register
303
     * @param int $index
304
     * @param string $segment
305
     * @throws Exception
306
     */
307
    private function parseSegment(array &$register, $index, $segment)
308
    {
309
        $allowed = [false, false, false, self::MONTH_NAMES, self::WEEKDAY_NAMES];
310
311 93
        // month names, weekdays
312
        if ($allowed[$index] !== false && isset($allowed[$index][strtolower($segment)])) {
313 93
            // cannot be used together with lists or ranges
314
            $register[$allowed[$index][strtolower($segment)]] = true;
315 93
        } else {
316 89
            // split up current segment into single elements, e.g. "1,5-7,*/2" => [ "1", "5-7", "*/2" ]
317 89
            foreach (explode(',', $segment) as $element) {
318
                $this->parseElement($register, $index, $element);
319
            }
320 66
        }
321 66
    }
322
323
    /**
324 4
     * @param array $register
325
     * @param int $index
326
     * @param string $element
327 66
     * @throws Exception
328
     */
329
    private function parseElement(array &$register, int $index, string $element)
330
    {
331
        $step = 1;
332
        $segments = explode('/', $element);
333
334
        if (sizeof($segments) > 1) {
335
            $this->validateStepping($segments, $index);
336
337
            $element = (string)$segments[0];
338 89
            $step = (int)$segments[1];
339
        }
340 89
341
        if (is_numeric($element)) {
342
            $this->validateValue($element, $index, $step);
343 89
            $register[intval($element)] = true;
344
        } else {
345 5
            $this->parseRange($register, $index, $element, $step);
346
        }
347
    }
348 89
349 89
    /**
350
     * Parse range of values, e.g. "5-10"
351
     *
352 77
     * @param array $register
353
     * @param int $index
354
     * @param string $range
355
     * @param int $stepping
356
     * @throws Exception
357
     */
358
    private function parseRange(array &$register, int $index, string $range, int $stepping)
359
    {
360 89
        if ($range === '*') {
361
            $range = [self::VALUE_BOUNDARIES[$index]['min'], self::VALUE_BOUNDARIES[$index]['max']];
362 89
        } else {
363
            $range = explode('-', $range);
364 89
        }
365 42
366
        $this->validateRange($range, $index);
367
        $this->fillRange($register, $index, $range, $stepping);
368 86
    }
369 35
370
    /**
371 29
     * Validate whether a given range of values exceeds allowed value boundaries
372 1
     *
373
     * @param array $range
374
     * @param int $index
375 28
     * @throws Exception
376
     */
377 82
    private function validateRange(array $range, int $index)
378
    {
379 78
        if (sizeof($range) !== 2) {
380
            throw new Exception('invalid range notation');
381
        }
382
383
        foreach ($range as $value) {
384
            $this->validateValue($value, $index);
385
        }
386
    }
387
388
    /**
389
     * @param string $value
390 82
     * @param int $index
391
     * @param int $step
392 82
     * @throws Exception
393 75
     */
394 51
    private function validateValue(string $value, int $index, int $step = 1)
395 44
    {
396
        if ((string)$value !== (string)(int)$value) {
397 8
            throw new Exception('non-integer value');
398
        }
399
400 77
        if (intval($value) < self::VALUE_BOUNDARIES[$index]['min'] ||
401 77
            intval($value) > self::VALUE_BOUNDARIES[$index]['max']
402
        ) {
403
            throw new Exception('value out of boundary');
404
        }
405
406
        if ($step !== 1) {
407
            throw new Exception('invalid combination of value and stepping notation');
408
        }
409
    }
410
411 42
    /**
412
     * @param array $segments
413 42
     * @param int $index
414
     * @throws Exception
415 42
     */
416
    private function validateStepping(array $segments, int $index)
417 38
    {
418 38
        if (sizeof($segments) !== 2) {
419 38
            throw new Exception('invalid stepping notation');
420
        }
421
422
        if ((int)$segments[1] < 1 || (int)$segments[1] > self::VALUE_BOUNDARIES[$index]['max']) {
423
            throw new Exception('stepping out of allowed range');
424
        }
425
    }
426
427
    /**
428
     * @param array $register
429 44
     * @param int $index
430
     * @param array $range
431 44
     * @param int $stepping
432 1
     */
433
    private function fillRange(array &$register, int $index, array $range, int $stepping)
434
    {
435 44
        $boundary = self::VALUE_BOUNDARIES[$index]['max'] + self::VALUE_BOUNDARIES[$index]['mod'];
436 44
        $length = $range[1] - $range[0];
437
438
        if ($range[0] > $range[1]) {
439 41
            $length += $boundary;
440
        }
441
442
        for ($i = 0; $i <= $length; $i += $stepping) {
443
            $register[($range[0] + $i) % $boundary] = true;
444
        }
445
    }
446
}
447