Passed
Pull Request — main (#17)
by
unknown
11:55
created

CronExpression::toDateTime()   A

Complexity

Conditions 5
Paths 6

Size

Total Lines 18
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 11
CRAP Score 5

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 5
eloc 11
c 1
b 0
f 0
nc 6
nop 1
dl 0
loc 18
ccs 11
cts 11
cp 1
crap 5
rs 9.6111
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
    protected $expression;
94
95
    /**
96
     * @param string $expression a cron expression, e.g. "* * * * *"
97
     * @param DateTimeZone|null $timeZone time zone object
98
     */
99 118
    public function __construct(string $expression, DateTimeZone $timeZone = null)
100
    {
101 118
        $this->timeZone = $timeZone;
102 118
        $this->expression = $expression;
103
104
        try {
105 118
            $this->registers = $this->parse($expression);
106 33
        } catch (Exception $e) {
107 33
            $this->registers = null;
108
        }
109
    }
110
111
    /**
112
     * Whether current cron expression has been parsed successfully
113
     *
114
     * @return bool
115
     */
116 118
    public function isValid(): bool
117
    {
118 118
        return null !== $this->registers;
119
    }
120
121
    /**
122
     * Match either "now", a given date/time object or a timestamp against current cron expression
123
     *
124
     * @param mixed $when a DateTime object, a timestamp (int), or "now" if not set
125
     * @return bool
126
     * @throws Exception
127
     */
128 117
    public function isMatching($when = null): bool
129
    {
130 117
        if (false === ($when instanceof DateTimeInterface)) {
131 99
            $when = (new DateTime())->setTimestamp($when === null ? time() : $when);
132
        }
133
134 117
        if ($this->timeZone !== null) {
135 115
            $when->setTimezone($this->timeZone);
136
        }
137
138 117
        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
The function sscanf was not found. Maybe you did not declare it correctly or list all dependencies? ( Ignorable by Annotation )

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

138
        return $this->isValid() && $this->match(/** @scrutinizer ignore-call */ sscanf($when->format('i G j n w'), '%d %d %d %d %d'));
Loading history...
139
    }
140
141
    /**
142
     * Calculate next matching timestamp
143
     *
144
     * @param mixed $start a DateTime object, a timestamp (int) or "now" if not set
145
     * @return int|bool next matching timestamp, or false on error
146
     * @throws Exception
147
     */
148 18
    public function getNext($start = null)
149
    {
150 18
        if ($this->isValid()) {
151 17
            $next = $this->toDateTime($start);
152
153
            do {
154 17
                $pos = sscanf($next->format('i G j n Y w'), '%d %d %d %d %d %d');
0 ignored issues
show
Bug introduced by
The function sscanf was not found. Maybe you did not declare it correctly or list all dependencies? ( Ignorable by Annotation )

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

154
                $pos = /** @scrutinizer ignore-call */ sscanf($next->format('i G j n Y w'), '%d %d %d %d %d %d');
Loading history...
155 17
            } while ($this->increase($next, $pos));
156
157 17
            return $next->getTimestamp();
158
        }
159
160 1
        return false;
161
    }
162
163
    /**
164
     * @param mixed $start a DateTime object, a timestamp (int) or "now" if not set
165
     * @return DateTime
166
     */
167 17
    private function toDateTime($start): DateTime
168
    {
169 17
        if ($start instanceof DateTimeInterface) {
170 1
            $next = $start;
171 16
        } elseif ((int)$start > 0) {
172 15
            $next = new DateTime('@' . $start);
173
        } else {
174 1
            $next = new DateTime('@' . time());
175
        }
176
177 17
        $next->setTimestamp($next->getTimeStamp() - $next->getTimeStamp() % 60);
178 17
        $next->setTimezone($this->timeZone ?: new DateTimeZone(date_default_timezone_get()));
179
180 17
        if ($this->isMatching($next)) {
181 5
            $next->modify('+1 minute');
182
        }
183
184 17
        return $next;
185
    }
186
187
    /**
188
     * Increases the timestamp in step sizes depending on which segment(s) of the cron pattern are matching.
189
     * Returns FALSE if the cron pattern is matching and thus no further cycle is required.
190
     *
191
     * @param DateTimeInterface $next
192
     * @param array $pos
193
     * @return bool
194
     */
195 17
    private function increase(DateTimeInterface $next, array $pos): bool
196
    {
197
        switch (true) {
198 17
            case false === isset($this->registers[3][$pos[3]]):
199
                // next month, reset day/hour/minute
200 3
                $next->setTime(0, 0);
201 3
                $next->setDate($pos[4], $pos[3], 1);
202 3
                $next->modify('+1 month');
203 3
                return true;
204
205 17
            case false === (isset($this->registers[2][$pos[2]]) && isset($this->registers[4][$pos[5]])):
206
                // next day, reset hour/minute
207 2
                $next->setTime(0, 0);
208 2
                $next->modify('+1 day');
209 2
                return true;
210
211 17
            case false === isset($this->registers[1][$pos[1]]):
212
                // next hour, reset minute
213 8
                $next->setTime($pos[1], 0);
214 8
                $next->modify('+1 hour');
215 8
                return true;
216
217 17
            case false === isset($this->registers[0][$pos[0]]):
218
                // next minute
219 10
                $next->modify('+1 minute');
220 10
                return true;
221
222
            default:
223
                // all segments are matching
224 17
                return false;
225
        }
226
    }
227
228
    /**
229
     * @param array $segments
230
     * @return bool
231
     */
232 85
    private function match(array $segments): bool
233
    {
234 85
        foreach ($this->registers as $i => $item) {
235 85
            if (isset($item[(int)$segments[$i]]) === false) {
236 42
                return false;
237
            }
238
        }
239
240 44
        return true;
241
    }
242
243
    /**
244
     * Parse whole cron expression
245
     *
246
     * @param string $expression
247
     * @return array
248
     * @throws Exception
249
     */
250 118
    private function parse(string $expression): array
251
    {
252 118
        $segments = preg_split('/\s+/', trim($expression));
253
254 118
        if (is_array($segments) && sizeof($segments) === 5) {
0 ignored issues
show
Bug introduced by
The function is_array was not found. Maybe you did not declare it correctly or list all dependencies? ( Ignorable by Annotation )

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

254
        if (/** @scrutinizer ignore-call */ is_array($segments) && sizeof($segments) === 5) {
Loading history...
255 113
            $registers = array_fill(0, 5, []);
256
257 113
            foreach ($segments as $index => $segment) {
258 113
                $this->parseSegment($registers[$index], $index, $segment);
259
            }
260
261 85
            if (isset($registers[4][7])) {
262 2
                $registers[4][0] = true;
263
            }
264
265 85
            return $registers;
266
        }
267
268 5
        throw new Exception('invalid number of segments');
269
    }
270
271
    /**
272
     * Parse one segment of a cron expression
273
     *
274
     * @param array $register
275
     * @param int $index
276
     * @param string $segment
277
     * @throws Exception
278
     */
279 113
    private function parseSegment(array &$register, int $index, string $segment): void
280
    {
281 113
        $allowed = [false, false, false, self::MONTH_NAMES, self::WEEKDAY_NAMES];
282
283
        // month names, weekdays
284 113
        if ($allowed[$index] !== false && isset($allowed[$index][strtolower($segment)])) {
285
            // cannot be used together with lists or ranges
286 5
            $register[$allowed[$index][strtolower($segment)]] = true;
287
        } else {
288
            // split up current segment into single elements, e.g. "1,5-7,*/2" => [ "1", "5-7", "*/2" ]
289 113
            foreach (explode(',', $segment) as $element) {
290 113
                $this->parseElement($register, $index, $element);
291
            }
292
        }
293
    }
294
295
    /**
296
     * @param array $register
297
     * @param int $index
298
     * @param string $element
299
     * @throws Exception
300
     */
301 113
    private function parseElement(array &$register, int $index, string $element): void
302
    {
303 113
        $step = 1;
304 113
        $segments = explode('/', $element);
305
306 113
        if (sizeof($segments) > 1) {
307 60
            $this->validateStepping($segments, $index);
308
309 56
            $element = (string)$segments[0];
310 56
            $step = (int)$segments[1];
311
        }
312
313 110
        if (is_numeric($element)) {
0 ignored issues
show
Bug introduced by
The function is_numeric was not found. Maybe you did not declare it correctly or list all dependencies? ( Ignorable by Annotation )

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

313
        if (/** @scrutinizer ignore-call */ is_numeric($element)) {
Loading history...
314 60
            $this->validateValue($element, $index, $step);
315 53
            $register[intval($element)] = true;
0 ignored issues
show
Bug introduced by
The function intval was not found. Maybe you did not declare it correctly or list all dependencies? ( Ignorable by Annotation )

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

315
            $register[/** @scrutinizer ignore-call */ intval($element)] = true;
Loading history...
316
        } else {
317 106
            $this->parseRange($register, $index, $element, $step);
318
        }
319
    }
320
321
    /**
322
     * Parse range of values, e.g. "5-10"
323
     *
324
     * @param array $register
325
     * @param int $index
326
     * @param string $range
327
     * @param int $stepping
328
     * @throws Exception
329
     */
330 106
    private function parseRange(array &$register, int $index, string $range, int $stepping): void
331
    {
332 106
        if ($range === '*') {
333 96
            $rangeArr = [self::VALUE_BOUNDARIES[$index]['min'], self::VALUE_BOUNDARIES[$index]['max']];
334
        } else {
335 66
            $rangeArr = explode('-', $range);
336
        }
337
338 106
        $this->validateRange($rangeArr, $index);
339 98
        $this->fillRange($register, $index, $rangeArr, $stepping);
340
    }
341
342
    /**
343
     * @param array $register
344
     * @param int $index
345
     * @param array $range
346
     * @param int $stepping
347
     */
348 98
    private function fillRange(array &$register, int $index, array $range, int $stepping): void
349
    {
350 98
        $boundary = self::VALUE_BOUNDARIES[$index]['max'] + self::VALUE_BOUNDARIES[$index]['mod'];
351 98
        $length = $range[1] - $range[0];
352
353 98
        if ($range[0] > $range[1]) {
354
            $length += $boundary;
355
        }
356
357 98
        for ($i = 0; $i <= $length; $i += $stepping) {
358 98
            $register[($range[0] + $i) % $boundary] = true;
359
        }
360
    }
361
362
    /**
363
     * Validate whether a given range of values exceeds allowed value boundaries
364
     *
365
     * @param array $range
366
     * @param int $index
367
     * @throws Exception
368
     */
369 106
    private function validateRange(array $range, int $index): void
370
    {
371 106
        if (sizeof($range) !== 2) {
372 9
            throw new Exception('invalid range notation');
373
        }
374
375 103
        foreach ($range as $value) {
376 103
            $this->validateValue($value, $index);
377
        }
378
379 101
        if ($range[0] > $range[1]) {
380 5
            throw new Exception('lower value in range is larger than upper value');
381
        }
382
    }
383
384
    /**
385
     * @param string $value
386
     * @param int $index
387
     * @param int $step
388
     * @throws Exception
389
     */
390 107
    private function validateValue(string $value, int $index, int $step = 1): void
391
    {
392 107
        if ((string)$value !== (string)(int)$value) {
393 1
            throw new Exception('non-integer value');
394
        }
395
396 107
        if (intval($value) < self::VALUE_BOUNDARIES[$index]['min'] ||
0 ignored issues
show
Bug introduced by
The function intval was not found. Maybe you did not declare it correctly or list all dependencies? ( Ignorable by Annotation )

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

396
        if (/** @scrutinizer ignore-call */ intval($value) < self::VALUE_BOUNDARIES[$index]['min'] ||
Loading history...
397 107
            intval($value) > self::VALUE_BOUNDARIES[$index]['max']
398
        ) {
399 8
            throw new Exception('value out of boundary');
400
        }
401
402 105
        if ($step !== 1) {
403 1
            throw new Exception('invalid combination of value and stepping notation');
404
        }
405
    }
406
407
    /**
408
     * @param array $segments
409
     * @param int $index
410
     * @throws Exception
411
     */
412 60
    private function validateStepping(array $segments, int $index): void
413
    {
414 60
        if (sizeof($segments) !== 2) {
415 1
            throw new Exception('invalid stepping notation');
416
        }
417
418 59
        if ((int)$segments[1] < 1 || (int)$segments[1] > self::VALUE_BOUNDARIES[$index]['max']) {
419 3
            throw new Exception('stepping out of allowed range');
420
        }
421
    }
422
}
423