Completed
Push — master ( 339bb1...70de42 )
by René
02:24
created

Cron::isMatching()   B

Complexity

Conditions 6
Paths 15

Size

Total Lines 31
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Importance

Changes 4
Bugs 0 Features 0
Metric Value
c 4
b 0
f 0
dl 0
loc 31
rs 8.439
cc 6
eloc 18
nc 15
nop 1
1
<?php
2
3
/**
4
 * Cron expression parser and validator
5
 *
6
 * @author René Pollesch
7
 */
8
class Cron
0 ignored issues
show
Coding Style Compatibility introduced by
PSR1 recommends that each class must be in a namespace of at least one level to avoid collisions.

You can fix this by adding a namespace to your class:

namespace YourVendor;

class YourClass { }

When choosing a vendor namespace, try to pick something that is not too generic to avoid conflicts with other libraries.

Loading history...
9
{
10
    /**
11
     * Weekday look-up table
12
     *
13
     * @var array
14
     */
15
    protected static $weekdays = [
16
        'sun' => 0,
17
        'mon' => 1,
18
        'tue' => 2,
19
        'wed' => 3,
20
        'thu' => 4,
21
        'fri' => 5,
22
        'sat' => 6
23
    ];
24
25
    /**
26
     * Month name look-up table
27
     *
28
     * @var array
29
     */
30
    protected static $months = [
31
        'jan' => 1,
32
        'feb' => 2,
33
        'mar' => 3,
34
        'apr' => 4,
35
        'may' => 5,
36
        'jun' => 6,
37
        'jul' => 7,
38
        'aug' => 8,
39
        'sep' => 9,
40
        'oct' => 10,
41
        'nov' => 11,
42
        'dec' => 12
43
    ];
44
45
    /**
46
     * Cron expression
47
     *
48
     * @var string
49
     */
50
    protected $expression;
51
52
    /**
53
     * Time zone
54
     *
55
     * @var \DateTimeZone
56
     */
57
    protected $timeZone;
58
59
    /**
60
     * Matching register
61
     *
62
     * @var array|null
63
     */
64
    protected $registers;
65
66
    /**
67
     * Class constructor sets cron expression property
68
     *
69
     * @param string $expression cron expression
70
     * @param \DateTimeZone $timeZone
71
     */
72
    public function __construct($expression = '* * * * *', \DateTimeZone $timeZone = null)
73
    {
74
        $this->setExpression($expression);
75
        $this->setTimeZone($timeZone);
76
    }
77
78
    /**
79
     * Set expression
80
     *
81
     * @param string $expression
82
     * @return self
83
     */
84
    public function setExpression($expression)
85
    {
86
        $this->expression = trim((string)$expression);
87
        $this->registers = null;
88
89
        return $this;
90
    }
91
92
    /**
93
     * Set time zone
94
     *
95
     * @param \DateTimeZone $timeZone
96
     * @return self
97
     */
98
    public function setTimeZone(\DateTimeZone $timeZone = null)
99
    {
100
        $this->timeZone = $timeZone;
101
        return $this;
102
    }
103
104
    /**
105
     * Parse and validate cron expression
106
     *
107
     * @return bool true if expression is valid, or false on error
108
     */
109
    public function isValid()
110
    {
111
        $result = true;
112
113
        if ($this->registers === null) {
114
            try {
115
                $this->registers = $this->parse();
116
            } catch (\Exception $e) {
117
                $result = false;
118
            }
119
        }
120
121
        return $result;
122
    }
123
124
    /**
125
     * Match current or given date/time against cron expression
126
     *
127
     * @param mixed $dtime \DateTime object, timestamp or null
128
     * @return bool
129
     */
130
    public function isMatching($dtime = null)
131
    {
132
        if ($dtime instanceof \DateTime) {
133
            $dtime->setTimezone($this->timeZone);
134
        } else {
135
            $dt = new \DateTime('now', $this->timeZone);
136
137
            if ((int)$dtime > 0) {
138
                $dt->setTimestamp($dtime);
139
            }
140
141
            $dtime = $dt;
142
        }
143
144
        $segments = sscanf($dtime->format('i G j n w'), '%d %d %d %d %d');
145
146
        try {
147
            $result = true;
148
149
            foreach ($this->parse() as $i => $item) {
150
                if (isset($item[(int)$segments[$i]]) === false) {
151
                    $result = false;
152
                    break;
153
                }
154
            }
155
        } catch (\Exception $e) {
156
            $result = false;
157
        }
158
159
        return $result;
160
    }
161
162
    /**
163
     * Calculate next matching timestamp
164
     *
165
     * @param mixed $dtime \DateTime object, timestamp or null
166
     * @return int|bool next matching timestamp, or false on error
167
     */
168
    public function getNext($dtime = null)
169
    {
170
        $result = false;
171
172
        if ($this->isValid()) {
173
            if ($dtime instanceof \DateTime) {
174
                $timestamp = $dtime->getTimestamp();
175
            } elseif ((int)$dtime > 0) {
176
                $timestamp = $dtime;
177
            } else {
178
                $timestamp = time();
179
            }
180
181
            $dt = new \DateTime('now', $this->timeZone);
182
            $dt->setTimestamp(ceil($timestamp / 60) * 60);
183
184
            list($pday, $pmonth, $pyear, $phour) = sscanf(
185
                $dt->format('j n Y G'),
186
                '%d %d %d %d'
187
            );
188
189
            while ($result === false) {
190
                list($minute, $hour, $day, $month, $year, $weekday) = sscanf(
191
                    $dt->format('i G j n Y w'),
192
                    '%d %d %d %d %d %d'
193
                );
194
195
                if ($pyear !== $year) {
196
                    $dt->setDate($year, 1, 1);
197
                    $dt->setTime(0, 0);
198
                } elseif ($pmonth !== $month) {
199
                    $dt->setDate($year, $month, 1);
200
                    $dt->setTime(0, 0);
201
                } elseif ($pday !== $day) {
202
                    $dt->setTime(0, 0);
203
                } elseif ($phour !== $hour) {
204
                    $dt->setTime($hour, 0);
205
                }
206
207
                list($pday, $pmonth, $pyear, $phour) = [$day, $month, $year, $hour];
208
209
                if (isset($this->registers[3][$month]) === false) {
210
                    $dt->modify('+1 month');
211
                    continue;
212
                } elseif (false === (isset($this->registers[2][$day]) && isset($this->registers[4][$weekday]))) {
213
                    $dt->modify('+1 day');
214
                    continue;
215
                } elseif (isset($this->registers[1][$hour]) === false) {
216
                    $dt->modify('+1 hour');
217
                    continue;
218
                } elseif (isset($this->registers[0][$minute]) === false) {
219
                    $dt->modify('+1 minute');
220
                    continue;
221
                }
222
223
                $result = $dt->getTimestamp();
224
            }
225
        }
226
227
        return $result;
228
    }
229
230
    /**
231
     * Parse cron expression and return expression parsed into matchable registers
232
     *
233
     * @return array
234
     * @throws \Exception
235
     */
236
    private function parse()
237
    {
238
        $registers = [];
239
240
        if (sizeof($segments = preg_split('/\s+/', $this->expression)) === 5) {
241
            foreach ($segments as $index => $segment) {
242
                $this->parseSegment($index, $segment, $registers);
243
            }
244
245
            $registers[4][0] = isset($registers[4][7]);
246
        } else {
247
            throw new \Exception('invalid number of segments');
248
        }
249
250
        return $registers;
251
    }
252
253
    /**
254
     * @param int $index
255
     * @param string $segment
256
     * @param array $registers
257
     * @throws \Exception
258
     */
259
    private function parseSegment($index, $segment, &$registers)
260
    {
261
        $strv = [false, false, false, self::$months, self::$weekdays];
262
263
        // month names, weekdays
264
        if ($strv[$index] !== false && isset($strv[$index][strtolower($segment)])) {
265
            // cannot be used with lists or ranges, see crontab(5) man page
266
            $registers[$index][$strv[$index][strtolower($segment)]] = true;
267
        } else {
268
            // split up current segment into single elements, e.g. "1,5-7,*/2" => [ "1", "5-7", "*/2" ]
269
            foreach (explode(',', $segment) as $element) {
270
                $this->parseElement($index, $element, $registers);
271
            }
272
        }
273
    }
274
275
    /**
276
     * @param int $index
277
     * @param string $element
278
     * @param array $registers
279
     * @throws \Exception
280
     */
281
    private function parseElement($index, $element, &$registers)
282
    {
283
        $stepping = 1;
284
        $minv = [0, 0, 1, 1, 0];
285
        $maxv = [59, 23, 31, 12, 7];
286
287
        // parse stepping notation
288
        if (strpos($element, '/') !== false) {
289
            if (sizeof($stepsegments = explode('/', $element)) === 2) {
290
                $element = $stepsegments[0];
291
292
                if (is_numeric($stepsegments[1])) {
293
                    if ($stepsegments[1] > 0 && $stepsegments[1] <= $maxv[$index]) {
294
                        $stepping = intval($stepsegments[1]);
295
                    } else {
296
                        throw new \Exception('stepping value out of allowed range');
297
                    }
298
                } else {
299
                    throw new \Exception('non-numeric stepping notation');
300
                }
301
            } else {
302
                throw new \Exception('invalid stepping notation');
303
            }
304
        }
305
306
        // single value
307
        if (is_numeric($element)) {
308
            if (intval($element) < $minv[$index] || intval($element) > $maxv[$index]) {
309
                throw new \Exception('value out of allowed range');
310
            }
311
312
            if ($stepping !== 1) {
313
                throw new \Exception('invalid combination of value and stepping notation');
314
            }
315
316
            $registers[$index][intval($element)] = true;
317
        } else {
318
            // asterisk indicates full range of values
319
            if ($element === '*') {
320
                $element = sprintf('%d-%d', $minv[$index], $maxv[$index]);
321
            }
322
323
            // range of values, e.g. "9-17"
324
            if (strpos($element, '-') !== false) {
325
                if (sizeof($ranges = explode('-', $element)) !== 2) {
326
                    throw new \Exception('invalid range notation');
327
                }
328
329
                // validate range
330
                foreach ($ranges as $range) {
331
                    if (is_numeric($range)) {
332
                        if (intval($range) < $minv[$index] || intval($range) > $maxv[$index]) {
333
                            throw new \Exception('invalid range start or end value');
334
                        }
335
                    } else {
336
                        throw new \Exception('non-numeric range notation');
337
                    }
338
                }
339
340
                // fill matching register
341
                if ($ranges[0] === $ranges[1]) {
342
                    $registers[$index][$ranges[0]] = true;
343
                } else {
344
                    for ($i = $minv[$index]; $i <= $maxv[$index]; $i++) {
345
                        if (($i - $ranges[0]) % $stepping === 0) {
346
                            if ($ranges[0] < $ranges[1]) {
347 View Code Duplication
                                if ($i >= $ranges[0] && $i <= $ranges[1]) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
348
                                    $registers[$index][$i] = true;
349
                                }
350 View Code Duplication
                            } elseif ($i >= $ranges[0] || $i <= $ranges[1]) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
351
                                $registers[$index][$i] = true;
352
                            }
353
                        }
354
                    }
355
                }
356
            } else {
357
                throw new \Exception('failed to parse list segment');
358
            }
359
        }
360
    }
361
}
362