Passed
Push — main ( 9e255f...b45b80 )
by René
06:57
created

CronExpression::increase()   A

Complexity

Conditions 6
Paths 5

Size

Total Lines 17
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 14
CRAP Score 6

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 6
eloc 13
c 1
b 0
f 0
nc 5
nop 2
dl 0
loc 17
ccs 14
cts 14
cp 1
crap 6
rs 9.2222
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
     * 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 112
    public function __construct(string $expression, DateTimeZone $timeZone = null)
98
    {
99 112
        $this->timeZone = $timeZone;
100
101
        try {
102 112
            $this->registers = $this->parse($expression);
103 27
        } catch (Exception $e) {
104 27
            $this->registers = null;
105
        }
106
    }
107
108
    /**
109
     * Whether current cron expression has been parsed successfully
110
     *
111
     * @return bool
112
     */
113 112
    public function isValid(): bool
114
    {
115 112
        return null !== $this->registers;
116
    }
117
118
    /**
119
     * 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 112
    public function isMatching($when = null): bool
126
    {
127 112
        if (false === ($when instanceof DateTimeInterface)) {
128 94
            $when = (new DateTime())->setTimestamp($when === null ? time() : $when);
129
        }
130
131 112
        if ($this->timeZone !== null) {
132 110
            $when->setTimezone($this->timeZone);
133
        }
134
135 112
        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

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