1 | <?php |
||||
2 | |||||
3 | namespace Poliander\Cron; |
||||
4 | |||||
5 | use \DateTime; |
||||
0 ignored issues
–
show
|
|||||
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. filter:
dependency_paths: ["lib/*"]
For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths ![]() |
|||||
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. filter:
dependency_paths: ["lib/*"]
For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths ![]() |
|||||
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. filter:
dependency_paths: ["lib/*"]
For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths ![]() |
|||||
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
![]() |
|||||
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
![]() |
|||||
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 |
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:For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths