Issues (6)

src/Cron/CronExpression.php (6 issues)

1
<?php
2
3
namespace Poliander\Cron;
4
5
use \DateTime;
0 ignored issues
show
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
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
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
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
     * @var DateTimeZone|null
81
     */
82
    protected readonly ?DateTimeZone $timeZone;
83
84
    /**
85
     * @var array|null
86
     */
87
    protected readonly ?array $registers;
88
89
    /**
90
     * @var string
91
     */
92
    protected readonly string $expression;
93
94
    /**
95
     * @param string $expression a cron expression, e.g. "* * * * *"
96
     * @param DateTimeZone|null $timeZone time zone object
97
     */
98 120
    public function __construct(string $expression, ?DateTimeZone $timeZone = null)
99
    {
100 120
        $this->timeZone = $timeZone;
101 120
        $this->expression = $expression;
102
103
        try {
104 120
            $this->registers = $this->parse($expression);
105 34
        } catch (Exception $e) {
106 34
            $this->registers = null;
107
        }
108
    }
109
110
    /**
111
     * Whether current cron expression has been parsed successfully
112
     *
113
     * @return bool
114
     */
115 120
    public function isValid(): bool
116
    {
117 120
        return null !== $this->registers;
118
    }
119
120
    /**
121
     * Match either "now", a given date/time object or a timestamp against current cron expression
122
     *
123
     * @param mixed $when a DateTime object, a timestamp (int), or "now" if not set
124
     * @return bool
125
     * @throws Exception
126
     */
127 119
    public function isMatching($when = null): bool
128
    {
129 119
        if (false === ($when instanceof DateTimeInterface)) {
130 101
            $when = (new DateTime())->setTimestamp($when === null ? time() : $when);
131
        }
132
133 119
        if ($this->timeZone !== null) {
134 117
            $when->setTimezone($this->timeZone);
135
        }
136
137 119
        return $this->isValid() && $this->match(sscanf($when->format('i G j n w'), '%d %d %d %d %d'));
0 ignored issues
show
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

137
        return $this->isValid() && $this->match(/** @scrutinizer ignore-type */ sscanf($when->format('i G j n w'), '%d %d %d %d %d'));
Loading history...
138
    }
139
140
    /**
141
     * Calculate next matching timestamp
142
     *
143
     * @param mixed $start a DateTime object, a timestamp (int) or "now" if not set
144
     * @return int|bool next matching timestamp, or false on error
145
     * @throws Exception
146
     */
147 18
    public function getNext($start = null)
148
    {
149 18
        if ($this->isValid()) {
150 17
            $next = $this->toDateTime($start);
151
152
            do {
153 17
                $pos = sscanf($next->format('i G j n Y w'), '%d %d %d %d %d %d');
154 17
            } while ($this->increase($next, $pos));
0 ignored issues
show
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

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