• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

TAKETODAY / today-infrastructure / 18154768944

01 Oct 2025 07:26AM UTC coverage: 81.882% (-0.005%) from 81.887%
18154768944

push

github

web-flow
Merge pull request #290 from TAKETODAY/dev/jspecify

jspecify

59788 of 78013 branches covered (76.64%)

Branch coverage included in aggregate %.

141239 of 167496 relevant lines covered (84.32%)

3.6 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

89.19
today-context/src/main/java/infra/scheduling/support/CronExpression.java
1
/*
2
 * Copyright 2017 - 2025 the original author or authors.
3
 *
4
 * This program 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
 * This program 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 this program. If not, see [https://www.gnu.org/licenses/]
16
 */
17

18
package infra.scheduling.support;
19

20
import org.jspecify.annotations.Nullable;
21

22
import java.time.temporal.ChronoUnit;
23
import java.time.temporal.Temporal;
24
import java.util.Arrays;
25

26
import infra.lang.Assert;
27
import infra.util.StringUtils;
28

29
/**
30
 * Representation of a
31
 * <a href="https://www.manpagez.com/man/5/crontab/">crontab expression</a>
32
 * that can calculate the next time it matches.
33
 *
34
 * <p>{@code CronExpression} instances are created through {@link #parse(String)};
35
 * the next match is determined with {@link #next(Temporal)}.
36
 *
37
 * <p>Supports a Quartz day-of-month/week field with an L/# expression. Follows
38
 * common cron conventions in every other respect, including 0-6 for SUN-SAT
39
 * (plus 7 for SUN as well). Note that Quartz deviates from the day-of-week
40
 * convention in cron through 1-7 for SUN-SAT whereas Infra strictly follows
41
 * cron even in combination with the optional Quartz-specific L/# expressions.
42
 *
43
 * @author Arjen Poutsma
44
 * @author <a href="https://github.com/TAKETODAY">Harry Yang</a>
45
 * @see CronTrigger
46
 * @since 4.0
47
 */
48
public final class CronExpression {
49

50
  static final int MAX_ATTEMPTS = 366;
51

52
  private static final String[] MACROS = new String[] {
60✔
53
          "@yearly", "0 0 0 1 1 *",
54
          "@annually", "0 0 0 1 1 *",
55
          "@monthly", "0 0 0 1 * *",
56
          "@weekly", "0 0 0 * * 0",
57
          "@daily", "0 0 0 * * *",
58
          "@midnight", "0 0 0 * * *",
59
          "@hourly", "0 0 * * * *"
60
  };
61

62
  private final CronField[] fields;
63

64
  private final String expression;
65

66
  private CronExpression(CronField seconds, CronField minutes, CronField hours,
67
          CronField daysOfMonth, CronField months, CronField daysOfWeek, String expression) {
2✔
68

69
    // Reverse order, to make big changes first.
70
    // To make sure we end up at 0 nanos, we add an extra field.
71
    this.fields = new CronField[] {
29✔
72
            daysOfWeek, months, daysOfMonth, hours, minutes, seconds, CronField.zeroNanos()
3✔
73
    };
74
    this.expression = expression;
3✔
75
  }
1✔
76

77
  /**
78
   * Parse the given
79
   * <a href="https://www.manpagez.com/man/5/crontab/">crontab expression</a>
80
   * string into a {@code CronExpression}.
81
   * The string has six single space-separated time and date fields:
82
   * <pre>
83
   * &#9484;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472; second (0-59)
84
   * &#9474; &#9484;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472; minute (0 - 59)
85
   * &#9474; &#9474; &#9484;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472; hour (0 - 23)
86
   * &#9474; &#9474; &#9474; &#9484;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472; day of the month (1 - 31)
87
   * &#9474; &#9474; &#9474; &#9474; &#9484;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472; month (1 - 12) (or JAN-DEC)
88
   * &#9474; &#9474; &#9474; &#9474; &#9474; &#9484;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472; day of the week (0 - 7)
89
   * &#9474; &#9474; &#9474; &#9474; &#9474; &#9474;          (0 or 7 is Sunday, or MON-SUN)
90
   * &#9474; &#9474; &#9474; &#9474; &#9474; &#9474;
91
   * &#42; &#42; &#42; &#42; &#42; &#42;
92
   * </pre>
93
   *
94
   * <p>The following rules apply:
95
   * <ul>
96
   * <li>
97
   * A field may be an asterisk ({@code *}), which always stands for
98
   * "first-last". For the "day of the month" or "day of the week" fields, a
99
   * question mark ({@code ?}) may be used instead of an asterisk.
100
   * </li>
101
   * <li>
102
   * Ranges of numbers are expressed by two numbers separated with a hyphen
103
   * ({@code -}). The specified range is inclusive.
104
   * </li>
105
   * <li>Following a range (or {@code *}) with {@code /n} specifies
106
   * the interval of the number's value through the range.
107
   * </li>
108
   * <li>
109
   * English names can also be used for the "month" and "day of week" fields.
110
   * Use the first three letters of the particular day or month (case does not
111
   * matter).
112
   * </li>
113
   * <li>
114
   * The "day of month" and "day of week" fields can contain a
115
   * {@code L}-character, which stands for "last", and has a different meaning
116
   * in each field:
117
   * <ul>
118
   * <li>
119
   * In the "day of month" field, {@code L} stands for "the last day of the
120
   * month". If followed by an negative offset (i.e. {@code L-n}), it means
121
   * "{@code n}th-to-last day of the month". If followed by {@code W} (i.e.
122
   * {@code LW}), it means "the last weekday of the month".
123
   * </li>
124
   * <li>
125
   * In the "day of week" field, {@code dL} or {@code DDDL} stands for
126
   * "the last day of week {@code d} (or {@code DDD}) in the month".
127
   * </li>
128
   * </ul>
129
   * </li>
130
   * <li>
131
   * The "day of month" field can be {@code nW}, which stands for "the nearest
132
   * weekday to day of the month {@code n}".
133
   * If {@code n} falls on Saturday, this yields the Friday before it.
134
   * If {@code n} falls on Sunday, this yields the Monday after,
135
   * which also happens if {@code n} is {@code 1} and falls on a Saturday
136
   * (i.e. {@code 1W} stands for "the first weekday of the month").
137
   * </li>
138
   * <li>
139
   * The "day of week" field can be {@code d#n} (or {@code DDD#n}), which
140
   * stands for "the {@code n}-th day of week {@code d} (or {@code DDD}) in
141
   * the month".
142
   * </li>
143
   * </ul>
144
   *
145
   * <p>Example expressions:
146
   * <ul>
147
   * <li>{@code "0 0 * * * *"} = the top of every hour of every day.</li>
148
   * <li><code>"*&#47;10 * * * * *"</code> = every ten seconds.</li>
149
   * <li>{@code "0 0 8-10 * * *"} = 8, 9 and 10 o'clock of every day.</li>
150
   * <li>{@code "0 0 6,19 * * *"} = 6:00 AM and 7:00 PM every day.</li>
151
   * <li>{@code "0 0/30 8-10 * * *"} = 8:00, 8:30, 9:00, 9:30, 10:00 and 10:30 every day.</li>
152
   * <li>{@code "0 0 9-17 * * MON-FRI"} = on the hour nine-to-five weekdays</li>
153
   * <li>{@code "0 0 0 25 12 ?"} = every Christmas Day at midnight</li>
154
   * <li>{@code "0 0 0 L * *"} = last day of the month at midnight</li>
155
   * <li>{@code "0 0 0 L-3 * *"} = third-to-last day of the month at midnight</li>
156
   * <li>{@code "0 0 0 1W * *"} = first weekday of the month at midnight</li>
157
   * <li>{@code "0 0 0 LW * *"} = last weekday of the month at midnight</li>
158
   * <li>{@code "0 0 0 * * 5L"} = last Friday of the month at midnight</li>
159
   * <li>{@code "0 0 0 * * THUL"} = last Thursday of the month at midnight</li>
160
   * <li>{@code "0 0 0 ? * 5#2"} = the second Friday in the month at midnight</li>
161
   * <li>{@code "0 0 0 ? * MON#1"} = the first Monday in the month at midnight</li>
162
   * </ul>
163
   *
164
   * <p>The following macros are also supported:
165
   * <ul>
166
   * <li>{@code "@yearly"} (or {@code "@annually"}) to run un once a year, i.e. {@code "0 0 0 1 1 *"},</li>
167
   * <li>{@code "@monthly"} to run once a month, i.e. {@code "0 0 0 1 * *"},</li>
168
   * <li>{@code "@weekly"} to run once a week, i.e. {@code "0 0 0 * * 0"},</li>
169
   * <li>{@code "@daily"} (or {@code "@midnight"}) to run once a day, i.e. {@code "0 0 0 * * *"},</li>
170
   * <li>{@code "@hourly"} to run once an hour, i.e. {@code "0 0 * * * *"}.</li>
171
   * </ul>
172
   *
173
   * @param expression the expression string to parse
174
   * @return the parsed {@code CronExpression} object
175
   * @throws IllegalArgumentException in the expression does not conform to
176
   * the cron format
177
   */
178
  public static CronExpression parse(String expression) {
179
    Assert.hasLength(expression, "Expression string must not be empty");
3✔
180

181
    expression = resolveMacros(expression);
3✔
182

183
    String[] fields = StringUtils.tokenizeToStringArray(expression, " ");
4✔
184
    if (fields.length != 6) {
4✔
185
      throw new IllegalArgumentException(String.format(
12✔
186
              "Cron expression must consist of 6 fields (found %d in \"%s\")", fields.length, expression));
6✔
187
    }
188
    try {
189
      CronField seconds = CronField.parseSeconds(fields[0]);
5✔
190
      CronField minutes = CronField.parseMinutes(fields[1]);
5✔
191
      CronField hours = CronField.parseHours(fields[2]);
5✔
192
      CronField daysOfMonth = CronField.parseDaysOfMonth(fields[3]);
5✔
193
      CronField months = CronField.parseMonth(fields[4]);
5✔
194
      CronField daysOfWeek = CronField.parseDaysOfWeek(fields[5]);
5✔
195

196
      return new CronExpression(seconds, minutes, hours, daysOfMonth, months, daysOfWeek, expression);
11✔
197
    }
198
    catch (IllegalArgumentException ex) {
1✔
199
      String msg = ex.getMessage() + " in cron expression \"" + expression + "\"";
5✔
200
      throw new IllegalArgumentException(msg, ex);
6✔
201
    }
202
  }
203

204
  /**
205
   * Determine whether the given string represents a valid cron expression.
206
   *
207
   * @param expression the expression to evaluate
208
   * @return {@code true} if the given expression is a valid cron expression
209
   */
210
  public static boolean isValidExpression(@Nullable String expression) {
211
    if (expression == null) {
2✔
212
      return false;
2✔
213
    }
214
    try {
215
      parse(expression);
3✔
216
      return true;
2✔
217
    }
218
    catch (IllegalArgumentException ex) {
1✔
219
      return false;
2✔
220
    }
221
  }
222

223
  private static String resolveMacros(String expression) {
224
    expression = expression.trim();
3✔
225
    for (int i = 0; i < MACROS.length; i = i + 2) {
11✔
226
      if (MACROS[i].equalsIgnoreCase(expression)) {
6✔
227
        return MACROS[i + 1];
6✔
228
      }
229
    }
230
    return expression;
2✔
231
  }
232

233
  /**
234
   * Calculate the next {@link Temporal} that matches this expression.
235
   *
236
   * @param temporal the seed value
237
   * @param <T> the type of temporal
238
   * @return the next temporal that matches this expression, or {@code null}
239
   * if no such temporal can be found
240
   */
241
  @Nullable
242
  public <T extends Temporal & Comparable<? super T>> T next(T temporal) {
243
    return nextOrSame(ChronoUnit.NANOS.addTo(temporal, 1));
7✔
244
  }
245

246
  @Nullable
247
  private <T extends Temporal & Comparable<? super T>> T nextOrSame(T temporal) {
248
    for (int i = 0; i < MAX_ATTEMPTS; i++) {
7✔
249
      T result = nextOrSameInternal(temporal);
4✔
250
      if (result == null || result.equals(temporal)) {
6!
251
        return result;
2✔
252
      }
253
      temporal = result;
2✔
254
    }
255
    return null;
2✔
256
  }
257

258
  @Nullable
259
  private <T extends Temporal & Comparable<? super T>> T nextOrSameInternal(T temporal) {
260
    for (CronField field : this.fields) {
17✔
261
      temporal = field.nextOrSame(temporal);
4✔
262
      if (temporal == null) {
2!
263
        return null;
×
264
      }
265
    }
266
    return temporal;
2✔
267
  }
268

269
  @Override
270
  public int hashCode() {
271
    return Arrays.hashCode(this.fields);
×
272
  }
273

274
  @Override
275
  public boolean equals(Object o) {
276
    if (this == o) {
3!
277
      return true;
×
278
    }
279
    if (o instanceof CronExpression other) {
6!
280
      return Arrays.equals(this.fields, other.fields);
6✔
281
    }
282
    else {
283
      return false;
×
284
    }
285
  }
286

287
  /**
288
   * Return the expression string used to create this {@code CronExpression}.
289
   *
290
   * @return the expression string
291
   */
292
  @Override
293
  public String toString() {
294
    return this.expression;
3✔
295
  }
296

297
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc