Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
43 / 43
100.00% covered (success)
100.00%
6 / 6
CRAP
100.00% covered (success)
100.00%
1 / 1
cron_expression
100.00% covered (success)
100.00%
43 / 43
100.00% covered (success)
100.00%
6 / 6
28
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 parse
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 is_valid
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 matches
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
6
 due_between
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 parse_field
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
12
1<?php
2// This file is part of Moodle - https://moodle.org/
3//
4// Moodle is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, either version 3 of the License, or
7// (at your option) any later version.
8//
9// Moodle is distributed in the hope that it will be useful,
10// but WITHOUT ANY WARRANTY; without even the implied warranty of
11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12// GNU General Public License for more details.
13//
14// You should have received a copy of the GNU General Public License
15// along with Moodle.  If not, see <https://www.gnu.org/licenses/>.
16
17declare(strict_types=1);
18
19namespace tool_autoflow\local\engine;
20
21/**
22 * A standard five-field cron expression: minute, hour, day of month, month, day of week.
23 *
24 * Supports *, lists (1,15), ranges (9-17), and steps (* /15, 10-40/10, 5/20).
25 * Day of week runs 0-7, where 0 and 7 are both Sunday. As in Vixie cron, when
26 * both day fields are restricted a moment matches if either one does.
27 *
28 * @package    tool_autoflow
29 * @copyright  2026 DevLMS <contact@devlms.com>
30 * @license    https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
31 */
32final class cron_expression {
33    /** @var int[][] Lowest and highest value per field. */
34    private const LIMITS = [[0, 59], [0, 23], [1, 31], [1, 12], [0, 7]];
35
36    /** @var int The longest window due_between() looks back over, in minutes (one day). */
37    private const MAX_LOOKBACK = 1440;
38
39    /**
40     * Use parse().
41     *
42     * @param array[] $allowed Per field, allowed values as keys.
43     * @param bool $dayrestricted Day of month is not *.
44     * @param bool $weekdayrestricted Day of week is not *.
45     */
46    private function __construct(
47        /** @var array[] Allowed values per field. */
48        private readonly array $allowed,
49        /** @var bool Day of month is restricted. */
50        private readonly bool $dayrestricted,
51        /** @var bool Day of week is restricted. */
52        private readonly bool $weekdayrestricted,
53    ) {
54    }
55
56    /**
57     * Parse an expression.
58     *
59     * @param string $expression
60     * @return self
61     * @throws \InvalidArgumentException
62     */
63    public static function parse(string $expression): self {
64        $fields = preg_split('/\s+/', trim($expression));
65        if (count($fields) !== 5) {
66            throw new \InvalidArgumentException("A cron expression needs 5 fields: \"$expression\".");
67        }
68        $allowed = [];
69        foreach ($fields as $i => $field) {
70            $allowed[$i] = self::parse_field($field, ...self::LIMITS[$i]);
71        }
72        if (isset($allowed[4][7])) {
73            $allowed[4][0] = true;
74        }
75        return new self($allowed, $fields[2] !== '*', $fields[4] !== '*');
76    }
77
78    /**
79     * Whether an expression parses.
80     *
81     * @param string $expression
82     * @return bool
83     */
84    public static function is_valid(string $expression): bool {
85        try {
86            self::parse($expression);
87            return true;
88        } catch (\InvalidArgumentException $e) {
89            return false;
90        }
91    }
92
93    /**
94     * Whether the expression matches a moment, read in the moment's own timezone.
95     *
96     * @param \DateTimeInterface $moment
97     * @return bool
98     */
99    public function matches(\DateTimeInterface $moment): bool {
100        [$minute, $hour, $day, $month, $weekday] = array_map('intval', explode(' ', $moment->format('i G j n w')));
101        if (!isset($this->allowed[0][$minute], $this->allowed[1][$hour], $this->allowed[3][$month])) {
102            return false;
103        }
104        $daymatch = isset($this->allowed[2][$day]);
105        $weekdaymatch = isset($this->allowed[4][$weekday]);
106        if ($this->dayrestricted && $this->weekdayrestricted) {
107            return $daymatch || $weekdaymatch;
108        }
109        return $daymatch && $weekdaymatch;
110    }
111
112    /**
113     * Whether any whole minute in (from, to] matches, in the given timezone.
114     *
115     * Used by the schedule task: from is the last time the workflow was queued,
116     * to is now. Several missed minutes still mean one run. Only the last day is
117     * checked, so a site whose cron was off for a week does not catch up old runs.
118     *
119     * @param int $from Unix time, not included.
120     * @param int $to Unix time, included.
121     * @param \DateTimeZone $timezone The site timezone.
122     * @return bool
123     */
124    public function due_between(int $from, int $to, \DateTimeZone $timezone): bool {
125        $last = intdiv($to, 60);
126        $first = max(intdiv($from, 60) + 1, $last - self::MAX_LOOKBACK + 1);
127        for ($minute = $last; $minute >= $first; $minute--) {
128            if ($this->matches((new \DateTimeImmutable('@' . ($minute * 60)))->setTimezone($timezone))) {
129                return true;
130            }
131        }
132        return false;
133    }
134
135    /**
136     * Parse one field into the set of values it allows.
137     *
138     * @param string $field
139     * @param int $min
140     * @param int $max
141     * @return array Allowed values as keys.
142     */
143    private static function parse_field(string $field, int $min, int $max): array {
144        $allowed = [];
145        foreach (explode(',', $field) as $part) {
146            $pattern = '/^(?:(?<all>\*)|(?<start>\d+)(?:-(?<end>\d+))?)(?:\/(?<step>\d+))?$/';
147            if (!preg_match($pattern, $part, $m, PREG_UNMATCHED_AS_NULL)) {
148                throw new \InvalidArgumentException("Cannot read \"$part\" in the cron expression.");
149            }
150            $step = $m['step'] === null ? 1 : (int) $m['step'];
151            if ($m['all'] !== null) {
152                [$start, $end] = [$min, $max];
153            } else {
154                $start = (int) $m['start'];
155                // From a start such as 5/20: up to the end of the range, every 20.
156                $end = $m['end'] !== null ? (int) $m['end'] : ($m['step'] !== null ? $max : $start);
157            }
158            if ($step < 1 || $start < $min || $end > $max || $start > $end) {
159                throw new \InvalidArgumentException("\"$part\" is out of range in the cron expression.");
160            }
161            for ($v = $start; $v <= $end; $v += $step) {
162                $allowed[$v] = true;
163            }
164        }
165        return $allowed;
166    }
167}