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