001/*
002 * Copyright 2002-2018 the original author or authors.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *      https://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016
017package org.springframework.scheduling.support;
018
019import java.util.ArrayList;
020import java.util.BitSet;
021import java.util.Calendar;
022import java.util.Collections;
023import java.util.Date;
024import java.util.GregorianCalendar;
025import java.util.List;
026import java.util.TimeZone;
027
028import org.springframework.lang.Nullable;
029import org.springframework.util.StringUtils;
030
031/**
032 * Date sequence generator for a
033 * <a href="https://www.manpagez.com/man/5/crontab/">Crontab pattern</a>,
034 * allowing clients to specify a pattern that the sequence matches.
035 *
036 * <p>The pattern is a list of six single space-separated fields: representing
037 * second, minute, hour, day, month, weekday. Month and weekday names can be
038 * given as the first three letters of the English names.
039 *
040 * <p>Example patterns:
041 * <ul>
042 * <li>"0 0 * * * *" = the top of every hour of every day.</li>
043 * <li>"*&#47;10 * * * * *" = every ten seconds.</li>
044 * <li>"0 0 8-10 * * *" = 8, 9 and 10 o'clock of every day.</li>
045 * <li>"0 0 6,19 * * *" = 6:00 AM and 7:00 PM every day.</li>
046 * <li>"0 0/30 8-10 * * *" = 8:00, 8:30, 9:00, 9:30, 10:00 and 10:30 every day.</li>
047 * <li>"0 0 9-17 * * MON-FRI" = on the hour nine-to-five weekdays</li>
048 * <li>"0 0 0 25 12 ?" = every Christmas Day at midnight</li>
049 * </ul>
050 *
051 * @author Dave Syer
052 * @author Juergen Hoeller
053 * @author Ruslan Sibgatullin
054 * @since 3.0
055 * @see CronTrigger
056 */
057public class CronSequenceGenerator {
058
059        private final String expression;
060
061        @Nullable
062        private final TimeZone timeZone;
063
064        private final BitSet months = new BitSet(12);
065
066        private final BitSet daysOfMonth = new BitSet(31);
067
068        private final BitSet daysOfWeek = new BitSet(7);
069
070        private final BitSet hours = new BitSet(24);
071
072        private final BitSet minutes = new BitSet(60);
073
074        private final BitSet seconds = new BitSet(60);
075
076
077        /**
078         * Construct a {@link CronSequenceGenerator} from the pattern provided,
079         * using the default {@link TimeZone}.
080         * @param expression a space-separated list of time fields
081         * @throws IllegalArgumentException if the pattern cannot be parsed
082         * @see java.util.TimeZone#getDefault()
083         */
084        public CronSequenceGenerator(String expression) {
085                this(expression, TimeZone.getDefault());
086        }
087
088        /**
089         * Construct a {@link CronSequenceGenerator} from the pattern provided,
090         * using the specified {@link TimeZone}.
091         * @param expression a space-separated list of time fields
092         * @param timeZone the TimeZone to use for generated trigger times
093         * @throws IllegalArgumentException if the pattern cannot be parsed
094         */
095        public CronSequenceGenerator(String expression, TimeZone timeZone) {
096                this.expression = expression;
097                this.timeZone = timeZone;
098                parse(expression);
099        }
100
101        private CronSequenceGenerator(String expression, String[] fields) {
102                this.expression = expression;
103                this.timeZone = null;
104                doParse(fields);
105        }
106
107
108        /**
109         * Return the cron pattern that this sequence generator has been built for.
110         */
111        String getExpression() {
112                return this.expression;
113        }
114
115
116        /**
117         * Get the next {@link Date} in the sequence matching the Cron pattern and
118         * after the value provided. The return value will have a whole number of
119         * seconds, and will be after the input value.
120         * @param date a seed value
121         * @return the next value matching the pattern
122         */
123        public Date next(Date date) {
124                /*
125                The plan:
126
127                1 Start with whole second (rounding up if necessary)
128
129                2 If seconds match move on, otherwise find the next match:
130                2.1 If next match is in the next minute then roll forwards
131
132                3 If minute matches move on, otherwise find the next match
133                3.1 If next match is in the next hour then roll forwards
134                3.2 Reset the seconds and go to 2
135
136                4 If hour matches move on, otherwise find the next match
137                4.1 If next match is in the next day then roll forwards,
138                4.2 Reset the minutes and seconds and go to 2
139                */
140
141                Calendar calendar = new GregorianCalendar();
142                calendar.setTimeZone(this.timeZone);
143                calendar.setTime(date);
144
145                // First, just reset the milliseconds and try to calculate from there...
146                calendar.set(Calendar.MILLISECOND, 0);
147                long originalTimestamp = calendar.getTimeInMillis();
148                doNext(calendar, calendar.get(Calendar.YEAR));
149
150                if (calendar.getTimeInMillis() == originalTimestamp) {
151                        // We arrived at the original timestamp - round up to the next whole second and try again...
152                        calendar.add(Calendar.SECOND, 1);
153                        doNext(calendar, calendar.get(Calendar.YEAR));
154                }
155
156                return calendar.getTime();
157        }
158
159        private void doNext(Calendar calendar, int dot) {
160                List<Integer> resets = new ArrayList<>();
161
162                int second = calendar.get(Calendar.SECOND);
163                List<Integer> emptyList = Collections.emptyList();
164                int updateSecond = findNext(this.seconds, second, calendar, Calendar.SECOND, Calendar.MINUTE, emptyList);
165                if (second == updateSecond) {
166                        resets.add(Calendar.SECOND);
167                }
168
169                int minute = calendar.get(Calendar.MINUTE);
170                int updateMinute = findNext(this.minutes, minute, calendar, Calendar.MINUTE, Calendar.HOUR_OF_DAY, resets);
171                if (minute == updateMinute) {
172                        resets.add(Calendar.MINUTE);
173                }
174                else {
175                        doNext(calendar, dot);
176                }
177
178                int hour = calendar.get(Calendar.HOUR_OF_DAY);
179                int updateHour = findNext(this.hours, hour, calendar, Calendar.HOUR_OF_DAY, Calendar.DAY_OF_WEEK, resets);
180                if (hour == updateHour) {
181                        resets.add(Calendar.HOUR_OF_DAY);
182                }
183                else {
184                        doNext(calendar, dot);
185                }
186
187                int dayOfWeek = calendar.get(Calendar.DAY_OF_WEEK);
188                int dayOfMonth = calendar.get(Calendar.DAY_OF_MONTH);
189                int updateDayOfMonth = findNextDay(calendar, this.daysOfMonth, dayOfMonth, this.daysOfWeek, dayOfWeek, resets);
190                if (dayOfMonth == updateDayOfMonth) {
191                        resets.add(Calendar.DAY_OF_MONTH);
192                }
193                else {
194                        doNext(calendar, dot);
195                }
196
197                int month = calendar.get(Calendar.MONTH);
198                int updateMonth = findNext(this.months, month, calendar, Calendar.MONTH, Calendar.YEAR, resets);
199                if (month != updateMonth) {
200                        if (calendar.get(Calendar.YEAR) - dot > 4) {
201                                throw new IllegalArgumentException("Invalid cron expression \"" + this.expression +
202                                                "\" led to runaway search for next trigger");
203                        }
204                        doNext(calendar, dot);
205                }
206
207        }
208
209        private int findNextDay(Calendar calendar, BitSet daysOfMonth, int dayOfMonth, BitSet daysOfWeek, int dayOfWeek,
210                        List<Integer> resets) {
211
212                int count = 0;
213                int max = 366;
214                // the DAY_OF_WEEK values in java.util.Calendar start with 1 (Sunday),
215                // but in the cron pattern, they start with 0, so we subtract 1 here
216                while ((!daysOfMonth.get(dayOfMonth) || !daysOfWeek.get(dayOfWeek - 1)) && count++ < max) {
217                        calendar.add(Calendar.DAY_OF_MONTH, 1);
218                        dayOfMonth = calendar.get(Calendar.DAY_OF_MONTH);
219                        dayOfWeek = calendar.get(Calendar.DAY_OF_WEEK);
220                        reset(calendar, resets);
221                }
222                if (count >= max) {
223                        throw new IllegalArgumentException("Overflow in day for expression \"" + this.expression + "\"");
224                }
225                return dayOfMonth;
226        }
227
228        /**
229         * Search the bits provided for the next set bit after the value provided,
230         * and reset the calendar.
231         * @param bits a {@link BitSet} representing the allowed values of the field
232         * @param value the current value of the field
233         * @param calendar the calendar to increment as we move through the bits
234         * @param field the field to increment in the calendar (@see
235         * {@link Calendar} for the static constants defining valid fields)
236         * @param lowerOrders the Calendar field ids that should be reset (i.e. the
237         * ones of lower significance than the field of interest)
238         * @return the value of the calendar field that is next in the sequence
239         */
240        private int findNext(BitSet bits, int value, Calendar calendar, int field, int nextField, List<Integer> lowerOrders) {
241                int nextValue = bits.nextSetBit(value);
242                // roll over if needed
243                if (nextValue == -1) {
244                        calendar.add(nextField, 1);
245                        reset(calendar, Collections.singletonList(field));
246                        nextValue = bits.nextSetBit(0);
247                }
248                if (nextValue != value) {
249                        calendar.set(field, nextValue);
250                        reset(calendar, lowerOrders);
251                }
252                return nextValue;
253        }
254
255        /**
256         * Reset the calendar setting all the fields provided to zero.
257         */
258        private void reset(Calendar calendar, List<Integer> fields) {
259                for (int field : fields) {
260                        calendar.set(field, field == Calendar.DAY_OF_MONTH ? 1 : 0);
261                }
262        }
263
264
265        // Parsing logic invoked by the constructor
266
267        /**
268         * Parse the given pattern expression.
269         */
270        private void parse(String expression) throws IllegalArgumentException {
271                String[] fields = StringUtils.tokenizeToStringArray(expression, " ");
272                if (!areValidCronFields(fields)) {
273                        throw new IllegalArgumentException(String.format(
274                                        "Cron expression must consist of 6 fields (found %d in \"%s\")", fields.length, expression));
275                }
276                doParse(fields);
277        }
278
279        private void doParse(String[] fields) {
280                setNumberHits(this.seconds, fields[0], 0, 60);
281                setNumberHits(this.minutes, fields[1], 0, 60);
282                setNumberHits(this.hours, fields[2], 0, 24);
283                setDaysOfMonth(this.daysOfMonth, fields[3]);
284                setMonths(this.months, fields[4]);
285                setDays(this.daysOfWeek, replaceOrdinals(fields[5], "SUN,MON,TUE,WED,THU,FRI,SAT"), 8);
286
287                if (this.daysOfWeek.get(7)) {
288                        // Sunday can be represented as 0 or 7
289                        this.daysOfWeek.set(0);
290                        this.daysOfWeek.clear(7);
291                }
292        }
293
294        /**
295         * Replace the values in the comma-separated list (case insensitive)
296         * with their index in the list.
297         * @return a new String with the values from the list replaced
298         */
299        private String replaceOrdinals(String value, String commaSeparatedList) {
300                String[] list = StringUtils.commaDelimitedListToStringArray(commaSeparatedList);
301                for (int i = 0; i < list.length; i++) {
302                        String item = list[i].toUpperCase();
303                        value = StringUtils.replace(value.toUpperCase(), item, "" + i);
304                }
305                return value;
306        }
307
308        private void setDaysOfMonth(BitSet bits, String field) {
309                int max = 31;
310                // Days of month start with 1 (in Cron and Calendar) so add one
311                setDays(bits, field, max + 1);
312                // ... and remove it from the front
313                bits.clear(0);
314        }
315
316        private void setDays(BitSet bits, String field, int max) {
317                if (field.contains("?")) {
318                        field = "*";
319                }
320                setNumberHits(bits, field, 0, max);
321        }
322
323        private void setMonths(BitSet bits, String value) {
324                int max = 12;
325                value = replaceOrdinals(value, "FOO,JAN,FEB,MAR,APR,MAY,JUN,JUL,AUG,SEP,OCT,NOV,DEC");
326                BitSet months = new BitSet(13);
327                // Months start with 1 in Cron and 0 in Calendar, so push the values first into a longer bit set
328                setNumberHits(months, value, 1, max + 1);
329                // ... and then rotate it to the front of the months
330                for (int i = 1; i <= max; i++) {
331                        if (months.get(i)) {
332                                bits.set(i - 1);
333                        }
334                }
335        }
336
337        private void setNumberHits(BitSet bits, String value, int min, int max) {
338                String[] fields = StringUtils.delimitedListToStringArray(value, ",");
339                for (String field : fields) {
340                        if (!field.contains("/")) {
341                                // Not an incrementer so it must be a range (possibly empty)
342                                int[] range = getRange(field, min, max);
343                                bits.set(range[0], range[1] + 1);
344                        }
345                        else {
346                                String[] split = StringUtils.delimitedListToStringArray(field, "/");
347                                if (split.length > 2) {
348                                        throw new IllegalArgumentException("Incrementer has more than two fields: '" +
349                                                        field + "' in expression \"" + this.expression + "\"");
350                                }
351                                int[] range = getRange(split[0], min, max);
352                                if (!split[0].contains("-")) {
353                                        range[1] = max - 1;
354                                }
355                                int delta = Integer.parseInt(split[1]);
356                                if (delta <= 0) {
357                                        throw new IllegalArgumentException("Incrementer delta must be 1 or higher: '" +
358                                                        field + "' in expression \"" + this.expression + "\"");
359                                }
360                                for (int i = range[0]; i <= range[1]; i += delta) {
361                                        bits.set(i);
362                                }
363                        }
364                }
365        }
366
367        private int[] getRange(String field, int min, int max) {
368                int[] result = new int[2];
369                if (field.contains("*")) {
370                        result[0] = min;
371                        result[1] = max - 1;
372                        return result;
373                }
374                if (!field.contains("-")) {
375                        result[0] = result[1] = Integer.parseInt(field);
376                }
377                else {
378                        String[] split = StringUtils.delimitedListToStringArray(field, "-");
379                        if (split.length > 2) {
380                                throw new IllegalArgumentException("Range has more than two fields: '" +
381                                                field + "' in expression \"" + this.expression + "\"");
382                        }
383                        result[0] = Integer.parseInt(split[0]);
384                        result[1] = Integer.parseInt(split[1]);
385                }
386                if (result[0] >= max || result[1] >= max) {
387                        throw new IllegalArgumentException("Range exceeds maximum (" + max + "): '" +
388                                        field + "' in expression \"" + this.expression + "\"");
389                }
390                if (result[0] < min || result[1] < min) {
391                        throw new IllegalArgumentException("Range less than minimum (" + min + "): '" +
392                                        field + "' in expression \"" + this.expression + "\"");
393                }
394                if (result[0] > result[1]) {
395                        throw new IllegalArgumentException("Invalid inverted range: '" + field +
396                                        "' in expression \"" + this.expression + "\"");
397                }
398                return result;
399        }
400
401
402        /**
403         * Determine whether the specified expression represents a valid cron pattern.
404         * @param expression the expression to evaluate
405         * @return {@code true} if the given expression is a valid cron expression
406         * @since 4.3
407         */
408        public static boolean isValidExpression(@Nullable String expression) {
409                if (expression == null) {
410                        return false;
411                }
412                String[] fields = StringUtils.tokenizeToStringArray(expression, " ");
413                if (!areValidCronFields(fields)) {
414                        return false;
415                }
416                try {
417                        new CronSequenceGenerator(expression, fields);
418                        return true;
419                }
420                catch (IllegalArgumentException ex) {
421                        return false;
422                }
423        }
424
425        private static boolean areValidCronFields(@Nullable String[] fields) {
426                return (fields != null && fields.length == 6);
427        }
428
429
430        @Override
431        public boolean equals(@Nullable Object other) {
432                if (this == other) {
433                        return true;
434                }
435                if (!(other instanceof CronSequenceGenerator)) {
436                        return false;
437                }
438                CronSequenceGenerator otherCron = (CronSequenceGenerator) other;
439                return (this.months.equals(otherCron.months) && this.daysOfMonth.equals(otherCron.daysOfMonth) &&
440                                this.daysOfWeek.equals(otherCron.daysOfWeek) && this.hours.equals(otherCron.hours) &&
441                                this.minutes.equals(otherCron.minutes) && this.seconds.equals(otherCron.seconds));
442        }
443
444        @Override
445        public int hashCode() {
446                return (17 * this.months.hashCode() + 29 * this.daysOfMonth.hashCode() + 37 * this.daysOfWeek.hashCode() +
447                                41 * this.hours.hashCode() + 53 * this.minutes.hashCode() + 61 * this.seconds.hashCode());
448        }
449
450        @Override
451        public String toString() {
452                return getClass().getSimpleName() + ": " + this.expression;
453        }
454
455}