Passed
Push — master ( 334377...abbc9f )
by René
01:35
created

CronExpression::getNext()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 16
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 10
CRAP Score 3

Importance

Changes 6
Bugs 0 Features 0
Metric Value
cc 3
eloc 9
c 6
b 0
f 0
nc 2
nop 1
dl 0
loc 16
ccs 10
cts 10
cp 1
crap 3
rs 9.9666
1
<?php
2
3
namespace Cron;
4
5
use \DateTime;
6
use \DateTimeInterface;
7
use \DateTimeZone;
8
use \Exception;
9
10
/**
11
 * Cron expression parser and validator
12
 *
13
 * @author René Pollesch
14
 */
15
class CronExpression
16
{
17
    /**
18
     * Weekday name look-up table
19
     */
20
    private const WEEKDAY_NAMES = [
21
        'sun' => 0,
22
        'mon' => 1,
23
        'tue' => 2,
24
        'wed' => 3,
25
        'thu' => 4,
26
        'fri' => 5,
27
        'sat' => 6
28
    ];
29
30
    /**
31
     * Month name look-up table
32
     */
33
    private const MONTH_NAMES = [
34
        'jan' => 1,
35
        'feb' => 2,
36
        'mar' => 3,
37
        'apr' => 4,
38
        'may' => 5,
39
        'jun' => 6,
40
        'jul' => 7,
41
        'aug' => 8,
42
        'sep' => 9,
43
        'oct' => 10,
44
        'nov' => 11,
45
        'dec' => 12
46
    ];
47
48
    /**
49
     * Value boundaries
50
     */
51
    private const VALUE_BOUNDARIES = [
52
        0 => [
53
            'min' => 0,
54
            'max' => 59,
55
            'mod' => 1
56
        ],
57
        1 => [
58
            'min' => 0,
59
            'max' => 23,
60
            'mod' => 1
61
        ],
62
        2 => [
63
            'min' => 1,
64
            'max' => 31,
65
            'mod' => 1
66
        ],
67
        3 => [
68
            'min' => 1,
69
            'max' => 12,
70
            'mod' => 1
71
        ],
72
        4 => [
73
            'min' => 0,
74
            'max' => 7,
75
            'mod' => 0
76
        ]
77
    ];
78
79
    /**
80
     * Time zone
81
     *
82
     * @var DateTimeZone|null
83
     */
84
    protected $timeZone = null;
85
86
    /**
87
     * Matching registers
88
     *
89
     * @var array|null
90
     */
91
    protected $registers = null;
92
93
    /**
94
     * @param string $expression a cron expression, e.g. "* * * * *"
95
     * @param DateTimeZone|null $timeZone time zone object
96
     */
97
    public function __construct(string $expression, DateTimeZone $timeZone = null)
98
    {
99
        $this->timeZone = $timeZone;
100
101
        try {
102 93
            $this->registers = $this->parse($expression);
103
        } catch (Exception $e) {
104 93
            $this->registers = null;
105 93
        }
106 93
    }
107
108
    /**
109
     * Whether current cron expression has been parsed successfully
110
     *
111
     * @return bool
112
     */
113
    public function isValid(): bool
114 93
    {
115
        return null !== $this->registers;
116 93
    }
117 93
118
    /**
119 93
     * Match either "now", a given date/time object or a timestamp against current cron expression
120
     *
121
     * @param mixed $when a DateTime object, a timestamp (int), or "now" if not set
122
     * @return bool
123
     * @throws Exception
124
     */
125
    public function isMatching($when = null): bool
126
    {
127
        if (false === ($when instanceof DateTimeInterface)) {
128 93
            $when = (new DateTime())->setTimestamp($when === null ? time() : $when);
129
        }
130 93
131 93
        if ($this->timeZone !== null) {
132
            $when->setTimezone($this->timeZone);
133
        }
134
135
        return $this->isValid() && $this->match(sscanf($when->format('i G j n w'), '%d %d %d %d %d'));
0 ignored issues
show
Bug introduced by
It seems like sscanf($when->format('i ... w'), '%d %d %d %d %d') can also be of type integer and null; however, parameter $segments of Cron\CronExpression::match() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

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