Passed
Pull Request — main (#25)
by
unknown
13:31
created

CronExpression::parse()   B

Complexity

Conditions 7
Paths 7

Size

Total Lines 31
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 12
CRAP Score 7.7656

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 7
eloc 15
c 1
b 0
f 0
nc 7
nop 1
dl 0
loc 31
ccs 12
cts 16
cp 0.75
crap 7.7656
rs 8.8333
1
<?php
2
3
namespace Poliander\Cron;
4
5
use \DateTime;
0 ignored issues
show
Bug introduced by
The type \DateTime was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
6
use \DateTimeInterface;
0 ignored issues
show
Bug introduced by
The type \DateTimeInterface was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
7
use \DateTimeZone;
0 ignored issues
show
Bug introduced by
The type \DateTimeZone was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
8
use \Exception;
0 ignored issues
show
Bug introduced by
The type \Exception was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
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
     * @expression look-up table
81
     */
82
    private const SPECIAL_EXPRESSIONS = [
83
        '@yearly' => '0 0 1 1 *',
84
        '@annually' => '0 0 1 1 *',
85
        '@monthly' => '0 0 1 * *',
86
        '@weekly' => '0 0 * * 0',
87
        '@daily' => '0 0 * * *',
88
        '@midnight' => '0 0 * * *',
89
        '@hourly' => '0 * * * *'
90
    ];
91
92
    /**
93
     * @var DateTimeZone|null
94
     */
95
    protected readonly ?DateTimeZone $timeZone;
96
97
    /**
98
     * @var array|null
99
     */
100
    protected readonly ?array $registers;
101
102
    /**
103
     * @var string
104
     */
105
    protected readonly string $expression;
106
107
    /**
108
     * @param string $expression a cron expression, e.g. "* * * * *"
109
     * @param DateTimeZone|null $timeZone time zone object
110
     */
111 120
    public function __construct(string $expression, DateTimeZone $timeZone = null)
112
    {
113 120
        $this->timeZone = $timeZone;
114 120
        $this->expression = $expression;
115
116
        try {
117 120
            $this->registers = $this->parse($expression);
118 34
        } catch (Exception $e) {
119 34
            $this->registers = null;
120
        }
121
    }
122
123
    /**
124
     * Whether current cron expression has been parsed successfully
125
     *
126
     * @return bool
127
     */
128 120
    public function isValid(): bool
129
    {
130 120
        return null !== $this->registers;
131
    }
132
133
    /**
134
     * Match either "now", a given date/time object or a timestamp against current cron expression
135
     *
136
     * @param mixed $when a DateTime object, a timestamp (int), or "now" if not set
137
     * @return bool
138
     * @throws Exception
139
     */
140 119
    public function isMatching($when = null): bool
141
    {
142 119
        if (false === ($when instanceof DateTimeInterface)) {
143 101
            $when = (new DateTime())->setTimestamp($when === null ? time() : $when);
144
        }
145
146 119
        if ($this->timeZone !== null) {
147 117
            $when->setTimezone($this->timeZone);
148
        }
149
150 119
        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 Poliander\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

150
        return $this->isValid() && $this->match(/** @scrutinizer ignore-type */ sscanf($when->format('i G j n w'), '%d %d %d %d %d'));
Loading history...
151
    }
152
153
    /**
154
     * Calculate next matching timestamp
155
     *
156
     * @param mixed $start a DateTime object, a timestamp (int) or "now" if not set
157
     * @return int|bool next matching timestamp, or false on error
158
     * @throws Exception
159
     */
160 18
    public function getNext($start = null)
161
    {
162 18
        if ($this->isValid()) {
163 17
            $next = $this->toDateTime($start);
164
165
            do {
166 17
                $pos = sscanf($next->format('i G j n Y w'), '%d %d %d %d %d %d');
167 17
            } while ($this->increase($next, $pos));
0 ignored issues
show
Bug introduced by
It seems like $pos can also be of type integer and null; however, parameter $pos of Poliander\Cron\CronExpression::increase() 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

167
            } while ($this->increase($next, /** @scrutinizer ignore-type */ $pos));
Loading history...
168
169 17
            return $next->getTimestamp();
170
        }
171
172 1
        return false;
173
    }
174
175
    /**
176
     * @param mixed $start a DateTime object, a timestamp (int) or "now" if not set
177
     * @return DateTime
178
     */
179 17
    private function toDateTime($start): DateTime
180
    {
181 17
        if ($start instanceof DateTimeInterface) {
182 1
            $next = $start;
183 16
        } elseif ((int)$start > 0) {
184 15
            $next = new DateTime('@' . $start);
185
        } else {
186 1
            $next = new DateTime('@' . time());
187
        }
188
189 17
        $next->setTimestamp($next->getTimeStamp() - $next->getTimeStamp() % 60);
190 17
        $next->setTimezone($this->timeZone ?: new DateTimeZone(date_default_timezone_get()));
191
192 17
        if ($this->isMatching($next)) {
193 5
            $next->modify('+1 minute');
194
        }
195
196 17
        return $next;
197
    }
198
199
    /**
200
     * Increases the timestamp in step sizes depending on which segment(s) of the cron pattern are matching.
201
     * Returns FALSE if the cron pattern is matching and thus no further cycle is required.
202
     *
203
     * @param DateTimeInterface $next
204
     * @param array $pos
205
     * @return bool
206
     */
207 17
    private function increase(DateTimeInterface $next, array $pos): bool
208
    {
209
        switch (true) {
210 17
            case false === isset($this->registers[3][$pos[3]]):
211
                // next month, reset day/hour/minute
212 3
                $next->setTime(0, 0);
213 3
                $next->setDate($pos[4], $pos[3], 1);
214 3
                $next->modify('+1 month');
215 3
                return true;
216
217 17
            case false === (isset($this->registers[2][$pos[2]]) && isset($this->registers[4][$pos[5]])):
218
                // next day, reset hour/minute
219 2
                $next->setTime(0, 0);
220 2
                $next->modify('+1 day');
221 2
                return true;
222
223 17
            case false === isset($this->registers[1][$pos[1]]):
224
                // next hour, reset minute
225 8
                $next->setTime($pos[1], 0);
226 8
                $next->modify('+1 hour');
227 8
                return true;
228
229 17
            case false === isset($this->registers[0][$pos[0]]):
230
                // next minute
231 10
                $next->modify('+1 minute');
232 10
                return true;
233
234
            default:
235
                // all segments are matching
236 17
                return false;
237
        }
238
    }
239
240
    /**
241
     * @param array $segments
242
     * @return bool
243
     */
244 86
    private function match(array $segments): bool
245
    {
246 86
        foreach ($this->registers as $i => $item) {
247 86
            if (isset($item[(int)$segments[$i]]) === false) {
248 42
                return false;
249
            }
250
        }
251
252 45
        return true;
253
    }
254
255
    /**
256
     * Parse whole cron expression
257
     *
258
     * @param string $expression
259
     * @return array
260
     * @throws Exception
261
     */
262 120
    private function parse(string $expression): array
263
    {
264 120
        $segments = preg_split('/\s+/', trim($expression));
265
266 120
        if (is_array($segments) && sizeof($segments) === 5) {
267 115
            $registers = array_fill(0, 5, []);
268
269 115
            foreach ($segments as $index => $segment) {
270 115
                $this->parseSegment($registers[$index], $index, $segment);
271
            }
272
273 87
            $this->validateDate($registers);
274
275 86
            if (isset($registers[4][7])) {
276 2
                $registers[4][0] = true;
277
            }
278
279 86
            return $registers;
280
281 5
        } else if (strpos($expression, '@') === 0) {
282
283
            $special = trim($expression);
284
285
            if (isset(self::SPECIAL_EXPRESSIONS[$special])) {
286
287
                $special_expression = self::SPECIAL_EXPRESSIONS[$special];
288
                return $this->parse($special_expression);
289
290
            }
291
        }
292 5
        throw new Exception('invalid number of segments');
293
    }
294
295
    /**
296
     * Parse one segment of a cron expression
297
     *
298
     * @param array $register
299
     * @param int $index
300
     * @param string $segment
301
     * @throws Exception
302
     */
303 115
    private function parseSegment(array &$register, int $index, string $segment): void
304
    {
305 115
        $allowed = [false, false, false, self::MONTH_NAMES, self::WEEKDAY_NAMES];
306
307
        // month names, weekdays
308 115
        if ($allowed[$index] !== false && isset($allowed[$index][strtolower($segment)])) {
309
            // cannot be used together with lists or ranges
310 5
            $register[$allowed[$index][strtolower($segment)]] = true;
311
        } else {
312
            // split up current segment into single elements, e.g. "1,5-7,*/2" => [ "1", "5-7", "*/2" ]
313 115
            foreach (explode(',', $segment) as $element) {
314 115
                $this->parseElement($register, $index, $element);
315
            }
316
        }
317
    }
318
319
    /**
320
     * @param array $register
321
     * @param int $index
322
     * @param string $element
323
     * @throws Exception
324
     */
325 115
    private function parseElement(array &$register, int $index, string $element): void
326
    {
327 115
        $step = 1;
328 115
        $segments = explode('/', $element);
329
330 115
        if (sizeof($segments) > 1) {
331 60
            $this->validateStepping($segments, $index);
332
333 56
            $element = (string)$segments[0];
334 56
            $step = (int)$segments[1];
335
        }
336
337 112
        if (is_numeric($element)) {
338 62
            $this->validateValue($element, $index, $step);
339 55
            $register[intval($element)] = true;
340
        } else {
341 108
            $this->parseRange($register, $index, $element, $step);
342
        }
343
    }
344
345
    /**
346
     * Parse range of values, e.g. "5-10"
347
     *
348
     * @param array $register
349
     * @param int $index
350
     * @param string $range
351
     * @param int $stepping
352
     * @throws Exception
353
     */
354 108
    private function parseRange(array &$register, int $index, string $range, int $stepping): void
355
    {
356 108
        if ($range === '*') {
357 98
            $rangeArr = [self::VALUE_BOUNDARIES[$index]['min'], self::VALUE_BOUNDARIES[$index]['max']];
358
        } else {
359 67
            $rangeArr = explode('-', $range);
360
        }
361
362 108
        $this->validateRange($rangeArr, $index);
363 100
        $this->fillRange($register, $index, $rangeArr, $stepping);
364
    }
365
366
    /**
367
     * @param array $register
368
     * @param int $index
369
     * @param array $range
370
     * @param int $stepping
371
     */
372 100
    private function fillRange(array &$register, int $index, array $range, int $stepping): void
373
    {
374 100
        $boundary = self::VALUE_BOUNDARIES[$index]['max'] + self::VALUE_BOUNDARIES[$index]['mod'];
375 100
        $length = $range[1] - $range[0];
376
377 100
        for ($i = 0; $i <= $length; $i += $stepping) {
378 100
            $register[($range[0] + $i) % $boundary] = true;
379
        }
380
    }
381
382
    /**
383
     * Validate whether a given range of values exceeds allowed value boundaries
384
     *
385
     * @param array $range
386
     * @param int $index
387
     * @throws Exception
388
     */
389 108
    private function validateRange(array $range, int $index): void
390
    {
391 108
        if (sizeof($range) !== 2) {
392 9
            throw new Exception('invalid range notation');
393
        }
394
395 105
        foreach ($range as $value) {
396 105
            $this->validateValue($value, $index);
397
        }
398
399 103
        if ($range[0] > $range[1]) {
400 5
            throw new Exception('lower value in range is larger than upper value');
401
        }
402
    }
403
404
    /**
405
     * @param string $value
406
     * @param int $index
407
     * @param int $step
408
     * @throws Exception
409
     */
410 109
    private function validateValue(string $value, int $index, int $step = 1): void
411
    {
412 109
        if (false === ctype_digit($value)) {
413 2
            throw new Exception('non-integer value');
414
        }
415
416 108
        if (intval($value) < self::VALUE_BOUNDARIES[$index]['min'] ||
417 108
            intval($value) > self::VALUE_BOUNDARIES[$index]['max']
418
        ) {
419 7
            throw new Exception('value out of boundary');
420
        }
421
422 107
        if ($step !== 1) {
423 1
            throw new Exception('invalid combination of value and stepping notation');
424
        }
425
    }
426
427
    /**
428
     * @param array $segments
429
     * @param int $index
430
     * @throws Exception
431
     */
432 60
    private function validateStepping(array $segments, int $index): void
433
    {
434 60
        if (sizeof($segments) !== 2) {
435 1
            throw new Exception('invalid stepping notation');
436
        }
437
438 59
        if ((int)$segments[1] < 1 || (int)$segments[1] > self::VALUE_BOUNDARIES[$index]['max']) {
439 3
            throw new Exception('stepping out of allowed range');
440
        }
441
    }
442
443
    /**
444
     * @param array $segments
445
     * @throws Exception
446
     */
447 87
    private function validateDate(array $segments): void
448
    {
449 87
        $year = date('Y');
450
451 87
        for ($y = 0; $y < 27; $y++) {
452 87
            foreach (array_keys($segments[3]) as $month) {
453 87
                foreach (array_keys($segments[2]) as $day) {
454 87
                    if (false === checkdate($month, $day, $year + $y)) {
455 1
                        continue;
456
                    }
457
458 86
                    if (false === isset($segments[date('w', strtotime(sprintf('%d-%d-%d', $year + $y, $month, $day)))])) {
459 1
                        continue;
460
                    }
461
462 86
                    return;
463
                }
464
            }
465
        }
466
467 1
        throw new Exception('no date ever can match the given combination of day/month/weekday');
468
    }
469
}
470