Passed
Push — master ( fda892...334377 )
by René
09:46
created

CronExpression::getNext()   B

Complexity

Conditions 7
Paths 7

Size

Total Lines 29
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 15
CRAP Score 7

Importance

Changes 5
Bugs 0 Features 0
Metric Value
cc 7
eloc 16
c 5
b 0
f 0
nc 7
nop 1
dl 0
loc 29
ccs 15
cts 15
cp 1
crap 7
rs 8.8333
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 = new DateTime('now', $this->timeZone ?: new DateTimeZone('GMT'));
151 1
152
            if ($start instanceof DateTimeInterface) {
153
                $now->setTimestamp($start->getTimestamp());
154 11
            } elseif ((int)$start > 0) {
155 11
                $now->setTimestamp($start);
156
            }
157 11
158 4
            $now->setTimestamp(intval(ceil($now->getTimeStamp() / 60)) * 60);
159
160
            if ($this->isMatching($now)) {
161 11
                $now->modify('+1 minute');
162
            }
163
164 11
            $pointer = sscanf($now->format('i G j n Y'), '%d %d %d %d %d');
165 11
166
            do {
167 11
                $current = $this->adjust($now, $pointer);
168
            } while ($this->forward($now, $current));
169
170 11
            $result = $now->getTimestamp();
171
        }
172
173
        return $result;
174
    }
175
176
    /**
177
     * @param DateTimeInterface $now
178 11
     * @param array $pointer
179
     * @return array
180 11
     */
181
    private function adjust(DateTimeInterface $now, array &$pointer): array
182
    {
183 11
        $current = sscanf($now->format('i G j n Y w'), '%d %d %d %d %d %d');
184 6
185 6
        if ($pointer[1] !== $current[1]) {
186 6
            $pointer[1] = $current[1];
187
            $now->setTime($current[1], 0);
188 11
        } elseif ($pointer[0] !== $current[0]) {
189 9
            $pointer[0] = $current[0];
190 9
            $now->setTime($current[1], $current[0]);
191 9
        } elseif ($pointer[4] !== $current[4]) {
192
            $pointer[4] = $current[4];
193 11
            $now->setDate($current[4], 1, 1);
194 1
            $now->setTime(0, 0);
195 1
        } elseif ($pointer[3] !== $current[3]) {
196 1
            $pointer[3] = $current[3];
197 1
            $now->setDate($current[4], $current[3], 1);
198
            $now->setTime(0, 0);
199 11
        } elseif ($pointer[2] !== $current[2]) {
200 2
            $pointer[2] = $current[2];
201 2
            $now->setTime(0, 0);
202 2
        }
203 2
204
        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...
205 11
    }
206 2
207 2
    /**
208 2
     * @param DateTimeInterface $now
209
     * @param array $current
210
     * @return bool
211 11
     */
212
    private function forward(DateTimeInterface $now, array $current): bool
213
    {
214
        $result = false;
215
216
        if (isset($this->registers[3][$current[3]]) === false) {
217
            $now->modify('+1 month');
218
            $result = true;
219 11
        } elseif (false === (isset($this->registers[2][$current[2]]) && isset($this->registers[4][$current[5]]))) {
220
            $now->modify('+1 day');
221 11
            $result = true;
222
        } elseif (isset($this->registers[1][$current[1]]) === false) {
223 11
            $now->modify('+1 hour');
224 1
            $result = true;
225 1
        } elseif (isset($this->registers[0][$current[0]]) === false) {
226 11
            $now->modify('+1 minute');
227 2
            $result = true;
228 2
        }
229 11
230 4
        return $result;
231 4
    }
232 11
233 8
    /**
234 8
     * @param array $segments
235
     * @return bool
236
     */
237 11
    private function match(array $segments): bool
238
    {
239
        $result = true;
240
241
        foreach ($this->registers as $i => $item) {
242
            if (isset($item[(int)$segments[$i]]) === false) {
243
                $result = false;
244
                break;
245 92
            }
246
        }
247 92
248
        return $result;
249 92
    }
250
251 92
    /**
252 27
     * Parse whole cron expression
253 27
     *
254
     * @param string $expression
255
     * @return array
256
     * @throws Exception
257 92
     */
258
    private function parse(string $expression): array
259
    {
260
        $segments = preg_split('/\s+/', trim($expression));
261
262
        if (is_array($segments) && sizeof($segments) === 5) {
263
            $registers = array_fill(0, 5, []);
264
265
            foreach ($segments as $index => $segment) {
266
                $this->parseSegment($registers[$index], $index, $segment);
267 93
            }
268
269 93
            if (isset($registers[4][7])) {
270 81
                $registers[4][0] = true;
271
            }
272
273 93
            return $registers;
274 91
        }
275
276
        throw new Exception('invalid number of segments');
277
    }
278 93
279 27
    /**
280 27
     * Parse one segment of a cron expression
281
     *
282
     * @param array $register
283 93
     * @param int $index
284
     * @param string $segment
285
     * @throws Exception
286
     */
287
    private function parseSegment(array &$register, $index, $segment): void
288
    {
289
        $allowed = [false, false, false, self::MONTH_NAMES, self::WEEKDAY_NAMES];
290
291 93
        // month names, weekdays
292
        if ($allowed[$index] !== false && isset($allowed[$index][strtolower($segment)])) {
293 93
            // cannot be used together with lists or ranges
294
            $register[$allowed[$index][strtolower($segment)]] = true;
295 93
        } else {
296 66
            // split up current segment into single elements, e.g. "1,5-7,*/2" => [ "1", "5-7", "*/2" ]
297 32
            foreach (explode(',', $segment) as $element) {
298 66
                $this->parseElement($register, $index, $element);
299
            }
300
        }
301
    }
302 66
303
    /**
304
     * @param array $register
305
     * @param int $index
306
     * @param string $element
307
     * @throws Exception
308
     */
309
    private function parseElement(array &$register, int $index, string $element): void
310
    {
311 93
        $step = 1;
312
        $segments = explode('/', $element);
313 93
314
        if (sizeof($segments) > 1) {
315 93
            $this->validateStepping($segments, $index);
316 89
317 89
            $element = (string)$segments[0];
318
            $step = (int)$segments[1];
319
        }
320 66
321 66
        if (is_numeric($element)) {
322
            $this->validateValue($element, $index, $step);
323
            $register[intval($element)] = true;
324 4
        } else {
325
            $this->parseRange($register, $index, $element, $step);
326
        }
327 66
    }
328
329
    /**
330
     * Parse range of values, e.g. "5-10"
331
     *
332
     * @param array $register
333
     * @param int $index
334
     * @param string $range
335
     * @param int $stepping
336
     * @throws Exception
337
     */
338 89
    private function parseRange(array &$register, int $index, string $range, int $stepping): void
339
    {
340 89
        if ($range === '*') {
341
            $range = [self::VALUE_BOUNDARIES[$index]['min'], self::VALUE_BOUNDARIES[$index]['max']];
342
        } else {
343 89
            $range = explode('-', $range);
344
        }
345 5
346
        $this->validateRange($range, $index);
347
        $this->fillRange($register, $index, $range, $stepping);
348 89
    }
349 89
350
    /**
351
     * @param array $register
352 77
     * @param int $index
353
     * @param array $range
354
     * @param int $stepping
355
     */
356
    private function fillRange(array &$register, int $index, array $range, int $stepping): void
357
    {
358
        $boundary = self::VALUE_BOUNDARIES[$index]['max'] + self::VALUE_BOUNDARIES[$index]['mod'];
359
        $length = $range[1] - $range[0];
360 89
361
        if ($range[0] > $range[1]) {
362 89
            $length += $boundary;
363
        }
364 89
365 42
        for ($i = 0; $i <= $length; $i += $stepping) {
366
            $register[($range[0] + $i) % $boundary] = true;
367
        }
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): void
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): void
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): void
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