Completed
Push — master ( 65c666...3b3bdb )
by René
02:59
created

Cron   C

Complexity

Total Complexity 61

Size/Duplication

Total Lines 452
Duplicated Lines 1.33 %

Coupling/Cohesion

Components 1
Dependencies 0

Importance

Changes 17
Bugs 0 Features 0
Metric Value
wmc 61
c 17
b 0
f 0
lcom 1
cbo 0
dl 6
loc 452
rs 6.018

16 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 5 1
A setExpression() 0 7 1
A setTimeZone() 0 5 1
B isMatching() 0 31 6
A isValid() 0 14 3
C getNext() 0 61 14
A parse() 0 16 3
A parseSegment() 0 15 4
A parseElement() 0 20 4
A parseRange() 0 12 3
A validateRange() 0 12 3
A parseValues() 0 10 3
B parseValue() 6 10 6
A validateValue() 0 11 4
A parseStepping() 0 9 1
A validateStepping() 0 10 4

How to fix   Duplicated Code    Complexity   

Duplicated Code

Duplicate code is one of the most pungent code smells. A rule that is often used is to re-structure code once it is duplicated in three or more places.

Common duplication problems, and corresponding solutions are:

Complex Class

 Tip:   Before tackling complexity, make sure that you eliminate any duplication first. This often can reduce the size of classes significantly.

Complex classes like Cron often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Cron, and based on these observations, apply Extract Interface, too.

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
     * Value boundaries
47
     *
48
     * @var array
49
     */
50
    protected static $boundaries = [
51
        0 => [
52
            'min' => 0,
53
            'max' => 59
54
        ],
55
        1 => [
56
            'min' => 0,
57
            'max' => 23
58
        ],
59
        2 => [
60
            'min' => 1,
61
            'max' => 31
62
        ],
63
        3 => [
64
            'min' => 1,
65
            'max' => 12
66
        ],
67
        4 => [
68
            'min' => 0,
69
            'max' => 7
70
        ]
71
    ];
72
73
    /**
74
     * Cron expression
75
     *
76
     * @var string
77
     */
78
    protected $expression;
79
80
    /**
81
     * Time zone
82
     *
83
     * @var \DateTimeZone
84
     */
85
    protected $timeZone;
86
87
    /**
88
     * Matching register
89
     *
90
     * @var array|null
91
     */
92
    protected $register;
93
94
    /**
95
     * Class constructor sets cron expression property
96
     *
97
     * @param string $expression cron expression
98
     * @param \DateTimeZone $timeZone
99
     */
100
    public function __construct($expression = '* * * * *', \DateTimeZone $timeZone = null)
101
    {
102
        $this->setExpression($expression);
103
        $this->setTimeZone($timeZone);
104
    }
105
106
    /**
107
     * Set expression
108
     *
109
     * @param string $expression
110
     * @return self
111
     */
112
    public function setExpression($expression)
113
    {
114
        $this->expression = trim((string)$expression);
115
        $this->register = null;
116
117
        return $this;
118
    }
119
120
    /**
121
     * Set time zone
122
     *
123
     * @param \DateTimeZone $timeZone
124
     * @return self
125
     */
126
    public function setTimeZone(\DateTimeZone $timeZone = null)
127
    {
128
        $this->timeZone = $timeZone;
129
        return $this;
130
    }
131
132
    /**
133
     * Parse and validate cron expression
134
     *
135
     * @return bool true if expression is valid, or false on error
136
     */
137
    public function isValid()
138
    {
139
        $result = true;
140
141
        if ($this->register === null) {
142
            try {
143
                $this->register = $this->parse();
144
            } catch (\Exception $e) {
145
                $result = false;
146
            }
147
        }
148
149
        return $result;
150
    }
151
152
    /**
153
     * Match current or given date/time against cron expression
154
     *
155
     * @param mixed $dtime \DateTime object, timestamp or null
156
     * @return bool
157
     */
158
    public function isMatching($dtime = null)
159
    {
160
        if ($dtime instanceof \DateTime) {
161
            $dtime->setTimezone($this->timeZone);
162
        } else {
163
            $dt = new \DateTime('now', $this->timeZone);
164
165
            if ((int)$dtime > 0) {
166
                $dt->setTimestamp($dtime);
167
            }
168
169
            $dtime = $dt;
170
        }
171
172
        $segments = sscanf($dtime->format('i G j n w'), '%d %d %d %d %d');
173
174
        try {
175
            $result = true;
176
177
            foreach ($this->parse() as $i => $item) {
178
                if (isset($item[(int)$segments[$i]]) === false) {
179
                    $result = false;
180
                    break;
181
                }
182
            }
183
        } catch (\Exception $e) {
184
            $result = false;
185
        }
186
187
        return $result;
188
    }
189
190
    /**
191
     * Calculate next matching timestamp
192
     *
193
     * @param mixed $dtime \DateTime object, timestamp or null
194
     * @return int|bool next matching timestamp, or false on error
195
     */
196
    public function getNext($dtime = null)
197
    {
198
        $result = false;
199
200
        if ($this->isValid()) {
201
            if ($dtime instanceof \DateTime) {
202
                $timestamp = $dtime->getTimestamp();
203
            } elseif ((int)$dtime > 0) {
204
                $timestamp = $dtime;
205
            } else {
206
                $timestamp = time();
207
            }
208
209
            $dt = new \DateTime('now', $this->timeZone);
210
            $dt->setTimestamp(ceil($timestamp / 60) * 60);
211
212
            list($pday, $pmonth, $pyear, $phour) = sscanf(
213
                $dt->format('j n Y G'),
214
                '%d %d %d %d'
215
            );
216
217
            while ($result === false) {
218
                list($minute, $hour, $day, $month, $year, $weekday) = sscanf(
219
                    $dt->format('i G j n Y w'),
220
                    '%d %d %d %d %d %d'
221
                );
222
223
                if ($pyear !== $year) {
224
                    $dt->setDate($year, 1, 1);
225
                    $dt->setTime(0, 0);
226
                } elseif ($pmonth !== $month) {
227
                    $dt->setDate($year, $month, 1);
228
                    $dt->setTime(0, 0);
229
                } elseif ($pday !== $day) {
230
                    $dt->setTime(0, 0);
231
                } elseif ($phour !== $hour) {
232
                    $dt->setTime($hour, 0);
233
                }
234
235
                list($pday, $pmonth, $pyear, $phour) = [$day, $month, $year, $hour];
236
237
                if (isset($this->register[3][$month]) === false) {
238
                    $dt->modify('+1 month');
239
                    continue;
240
                } elseif (false === (isset($this->register[2][$day]) && isset($this->register[4][$weekday]))) {
241
                    $dt->modify('+1 day');
242
                    continue;
243
                } elseif (isset($this->register[1][$hour]) === false) {
244
                    $dt->modify('+1 hour');
245
                    continue;
246
                } elseif (isset($this->register[0][$minute]) === false) {
247
                    $dt->modify('+1 minute');
248
                    continue;
249
                }
250
251
                $result = $dt->getTimestamp();
252
            }
253
        }
254
255
        return $result;
256
    }
257
258
    /**
259
     * Parse whole cron expression
260
     *
261
     * @return array
262
     * @throws \Exception
263
     */
264
    private function parse()
265
    {
266
        $register = [];
267
268
        if (sizeof($segments = preg_split('/\s+/', $this->expression)) === 5) {
269
            foreach ($segments as $index => $segment) {
270
                $this->parseSegment($index, $register, $segment);
271
            }
272
273
            $register[4][0] = isset($register[4][7]);
274
        } else {
275
            throw new \Exception('invalid number of segments');
276
        }
277
278
        return $register;
279
    }
280
281
    /**
282
     * Parse one segment of a cron expression
283
     *
284
     * @param int $index
285
     * @param string $segment
286
     * @param array $register
287
     * @throws \Exception
288
     */
289
    private function parseSegment($index, array &$register, $segment)
290
    {
291
        $strv = [false, false, false, self::$months, self::$weekdays];
292
293
        // month names, weekdays
294
        if ($strv[$index] !== false && isset($strv[$index][strtolower($segment)])) {
295
            // cannot be used with lists or ranges, see crontab(5) man page
296
            $register[$index][$strv[$index][strtolower($segment)]] = true;
297
        } else {
298
            // split up current segment into single elements, e.g. "1,5-7,*/2" => [ "1", "5-7", "*/2" ]
299
            foreach (explode(',', $segment) as $element) {
300
                $this->parseElement($index, $register, $element);
301
            }
302
        }
303
    }
304
305
    /**
306
     * @param int $index
307
     * @param array $register
308
     * @param string $element
309
     * @throws \Exception
310
     */
311
    private function parseElement($index, array &$register, $element)
312
    {
313
        $stepping = 1;
314
315
        if (false !== strpos($element, '/')) {
316
            $this->parseStepping($index, $element, $stepping);
317
        }
318
319
        if (is_numeric($element)) {
320
            $this->validateValue($index, $element);
321
322
            if ($stepping !== 1) {
323
                throw new \Exception('invalid combination of value and stepping notation');
324
            }
325
326
            $register[$index][intval($element)] = true;
327
        } else {
328
            $this->parseRange($index, $register, $element, $stepping);
329
        }
330
    }
331
332
    /**
333
     * Parse range of values, e.g. "5-10"
334
     *
335
     * @param int $index
336
     * @param array $register
337
     * @param string $range
338
     * @param int $stepping
339
     * @throws \Exception
340
     */
341
    private function parseRange($index, array &$register, $range, $stepping)
342
    {
343
        if ($range === '*') {
344
            $range = [self::$boundaries[$index]['min'], self::$boundaries[$index]['max']];
345
        } elseif (strpos($range, '-') !== false) {
346
            $range = $this->validateRange($index, explode('-', $range));
347
        } else {
348
            throw new \Exception('failed to parse list segment');
349
        }
350
351
        $this->parseValues($index, $register, $range, $stepping);
352
    }
353
354
    /**
355
     * Validate whether a given range of values exceeds allowed value boundaries
356
     *
357
     * @param int $index
358
     * @param array $range
359
     * @return array
360
     * @throws \Exception
361
     */
362
    private function validateRange($index, array $range)
363
    {
364
        if (sizeof($range) !== 2) {
365
            throw new \Exception('invalid range notation');
366
        }
367
368
        foreach ($range as $value) {
369
            $this->validateValue($index, $value);
370
        }
371
372
        return $range;
373
    }
374
375
    /**
376
     * @param int $index
377
     * @param array $register
378
     * @param array $range
379
     * @param int $stepping
380
     */
381
    private function parseValues($index, array &$register, array $range, $stepping)
382
    {
383
        for ($i = self::$boundaries[$index]['min']; $i <= self::$boundaries[$index]['max']; $i++) {
384
            if (($i - $range[0]) % $stepping !== 0) {
385
                continue;
386
            }
387
388
            $this->parseValue($index, $register, $range, $i);
389
        }
390
    }
391
392
    /**
393
     * @param int $index
394
     * @param array $register
395
     * @param array $range
396
     * @param int $value
397
     */
398
    private function parseValue($index, array &$register, array $range, $value)
399
    {
400
        if ($range[0] < $range[1]) {
401 View Code Duplication
            if ($value >= $range[0] && $value <= $range[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...
402
                $register[$index][$value] = true;
403
            }
404 View Code Duplication
        } elseif ($value >= $range[0] || $value <= $range[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...
405
            $register[$index][$value] = true;
406
        }
407
    }
408
409
    /**
410
     * @param int $index
411
     * @param int $value
412
     * @throws \Exception
413
     */
414
    private function validateValue($index, $value)
415
    {
416
        if (is_numeric($value)) {
417
            if (intval($value) < self::$boundaries[$index]['min'] ||
418
                intval($value) > self::$boundaries[$index]['max']) {
419
                throw new \Exception('value boundary exceeded');
420
            }
421
        } else {
422
            throw new \Exception('non-integer value');
423
        }
424
    }
425
426
    /**
427
     * Parse stepping notation, e.g. "5-10/2" => 2
428
     *
429
     * @param int $index
430
     * @param string $element
431
     * @param int $stepping
432
     * @throws \Exception
433
     */
434
    private function parseStepping($index, &$element, &$stepping)
435
    {
436
        $segments = explode('/', $element);
437
438
        $this->validateStepping($index, $segments);
439
440
        $element = (string)$segments[0];
441
        $stepping = (int)$segments[1];
442
    }
443
444
    /**
445
     * @param int $index
446
     * @param array $segments
447
     * @throws \Exception
448
     */
449
    private function validateStepping($index, array $segments)
450
    {
451
        if (sizeof($segments) !== 2) {
452
            throw new \Exception('invalid stepping notation');
453
        }
454
455
        if ((int)$segments[1] <= 0 || (int)$segments[1] > self::$boundaries[$index]['max']) {
456
            throw new \Exception('stepping out of allowed range');
457
        }
458
    }
459
}
460