Cron Expression Explainer — Java source
Translate any 5-field cron expression into plain English, build one field-by-field, and preview the next time it will fire. Supports steps, ranges, lists, and named days/months. Runs 100% in your browser.
This is the Java implementation — the same logic the interactive tool runs, in a shareable, citable form.
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
// cron-explainer — 5-field cron parser, plain-English explainer, builder, and
// next-run calculator — Java polyglot showcase port.
//
// Language: Java (17+, standard library only)
// Source: CosmoDev polyglot showcase port of the "cron-explainer" tool,
// ported from src/lib/cron-explainer.ts — display source, part of
// CosmoDev's polyglot tool pages (dev.cosmolabs.org).
// License: MIT.
//
// Zero deps. Deterministic. Times are interpreted as UTC so results are
// unambiguous and DST-independent (the caller controls the instant). The
// scanner works on LocalDateTime fields directly — java.time's civil
// arithmetic is exactly the setUTC* semantics of the JS reference.
//
// The public surface mirrors the TypeScript reference: explainCron,
// buildCron, nextRun.
public final class CronExplainer {
private CronExplainer() {
}
// ─── Field model ────────────────────────────────────────────────────────
//
// The five cron fields in positional order, each with its numeric range and
// whether it accepts named tokens (JAN..DEC / SUN..SAT). wrapMax is true
// only for day-of-week, where 7 is treated as an alias for 0 (Sunday).
/** Positional name of a cron field. */
public enum FieldName {
MINUTE("minute"),
HOUR("hour"),
DAY_OF_MONTH("day-of-month"),
MONTH("month"),
DAY_OF_WEEK("day-of-week");
private final String label;
FieldName(String label) {
this.label = label;
}
/** The human label used in error messages and pluralised descriptions. */
public String label() {
return label;
}
}
/** Per-field metadata: numeric range plus parsing rules. */
record FieldMeta(FieldName name, int min, int max, boolean named, boolean wrapMax) {
}
/** The positional field table, indexed 0..4. */
private static final FieldMeta[] FIELDS = {
new FieldMeta(FieldName.MINUTE, 0, 59, false, false),
new FieldMeta(FieldName.HOUR, 0, 23, false, false),
new FieldMeta(FieldName.DAY_OF_MONTH, 1, 31, false, false),
new FieldMeta(FieldName.MONTH, 1, 12, true, false),
new FieldMeta(FieldName.DAY_OF_WEEK, 0, 7, true, true),
};
private static final String[] MONTH_NAMES = {
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
};
private static final String[] DOW_NAMES = {
"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday",
};
// Token tables as (token, value) pairs so iteration order is fixed. Order
// is irrelevant to the result here — no token is a substring of another —
// but a fixed order keeps the showcase deterministic.
private static final String[][] MONTH_TOKENS = {
{"JAN", "1"}, {"FEB", "2"}, {"MAR", "3"}, {"APR", "4"},
{"MAY", "5"}, {"JUN", "6"}, {"JUL", "7"}, {"AUG", "8"},
{"SEP", "9"}, {"OCT", "10"}, {"NOV", "11"}, {"DEC", "12"},
};
private static final String[][] DOW_TOKENS = {
{"SUN", "0"}, {"MON", "1"}, {"TUE", "2"}, {"WED", "3"},
{"THU", "4"}, {"FRI", "5"}, {"SAT", "6"},
};
/** Raised for a malformed field (the Python port's CronError is a ValueError). */
public static final class CronException extends IllegalArgumentException {
public CronException(String message) {
super(message);
}
}
private static String pad2(int n) {
return n < 10 ? "0" + n : Integer.toString(n);
}
private static String monthName(int m) {
return MONTH_NAMES[m - 1];
}
private static String dowName(int d) {
return DOW_NAMES[d % 7];
}
/** Inclusive integer range, e.g. inclusiveRange(1, 5) -> [1, 2, 3, 4, 5]. */
private static List<Integer> inclusiveRange(int lo, int hi) {
List<Integer> out = new ArrayList<>(hi - lo + 1);
for (int v = lo; v <= hi; v++) {
out.add(v);
}
return out;
}
/**
* Parse a strictly-numeric token (ASCII digits only). Rejects named tokens,
* signs, and surrounding garbage so malformed fields surface clearly.
*/
private static int parseIntStrict(String s, String label) {
String t = s.trim();
if (t.isEmpty() || !t.chars().allMatch(c -> c >= '0' && c <= '9')) {
throw new CronException(label + ": invalid number \"" + s + "\"");
}
return Integer.parseInt(t);
}
/**
* Replace named tokens (JAN..DEC / SUN..SAT) with their numeric values.
* Global substring replacement so ranges like "JUN-AUG" and lists like
* "MON,WED,FRI" normalize in a single pass over the field.
*/
private static String normalize(String value, FieldMeta meta) {
String v = value.trim().toUpperCase(Locale.ROOT);
if (!meta.named()) {
return v;
}
String[][] tokens = meta.name() == FieldName.MONTH ? MONTH_TOKENS : DOW_TOKENS;
for (String[] tv : tokens) {
v = v.replace(tv[0], tv[1]);
}
return v;
}
/** A field after expansion: the matched values plus the raw token and a flag
* distinguishing a bare {@code *} (wildcard) from an explicit enumeration. */
private record ParsedField(FieldMeta meta, String raw, List<Integer> values, boolean wildcard) {
}
/**
* Expand one field value into the explicit set of numbers it matches.
*
* <p>Handles {@code *}, {@code * / N}, {@code A-B}, {@code A-B/N}, {@code A}
* (single), {@code A/N} (A to field max), and comma-separated lists of any
* of these. Returns the deduped, sorted values plus a wildcard flag for a
* bare {@code *}.
*/
private static ParsedField expandField(String value, FieldMeta meta) {
String norm = normalize(value, meta);
if (norm.isEmpty()) {
throw new CronException(meta.name().label() + ": empty field");
}
if (norm.equals("*")) {
return new ParsedField(meta, value, inclusiveRange(meta.min(), meta.max()), true);
}
List<Integer> set = new ArrayList<>();
for (String term : norm.split(",", -1)) {
if (term.isEmpty()) {
throw new CronException(meta.name().label() + ": empty list item");
}
int slashIdx = term.indexOf('/');
String base = term;
int step = 1;
if (slashIdx != -1) {
base = term.substring(0, slashIdx);
step = parseIntStrict(term.substring(slashIdx + 1), meta.name().label());
if (step <= 0) {
throw new CronException(meta.name().label() + ": step must be a positive number");
}
}
int lo;
int hi;
if (base.equals("*")) {
lo = meta.min();
hi = meta.max();
} else {
int dash = base.indexOf('-');
if (dash != -1) {
lo = parseIntStrict(base.substring(0, dash), meta.name().label());
hi = parseIntStrict(base.substring(dash + 1), meta.name().label());
} else {
lo = parseIntStrict(base, meta.name().label());
// "A/step" runs from A to the field max; a bare "A" is a single value.
hi = slashIdx != -1 ? meta.max() : lo;
}
}
if (lo > hi) {
throw new CronException(
meta.name().label() + ": range start " + lo + " is greater than end " + hi);
}
if (lo < meta.min()) {
throw new CronException(
meta.name().label() + ": value " + lo + " is below minimum " + meta.min());
}
if (hi > meta.max()) {
throw new CronException(
meta.name().label() + ": value " + hi + " is above maximum " + meta.max());
}
for (int v = lo; v <= hi; v += step) {
int resolved = meta.wrapMax() && v == meta.max() ? meta.min() : v;
if (!set.contains(resolved)) {
set.add(resolved);
}
}
}
List<Integer> sorted = new ArrayList<>(set);
sorted.sort(null);
return new ParsedField(meta, value, sorted, false);
}
/** Parse all five fields, or throw a {@link CronException} carrying the
* human-readable error. */
private static List<ParsedField> parseExpr(String expr) {
String trimmed = expr.trim();
String[] tokens = trimmed.isEmpty() ? new String[0] : trimmed.split("\\s+");
if (tokens.length != 5) {
throw new CronException("Expected 5 fields (minute hour day-of-month month day-of-week), got "
+ tokens.length);
}
List<ParsedField> parts = new ArrayList<>(5);
for (int i = 0; i < 5; i++) {
parts.add(expandField(tokens[i], FIELDS[i]));
}
return parts;
}
/** True when a sorted value list is a contiguous run (e.g. [3, 4, 5, 6]). */
private static boolean isContiguous(List<Integer> values) {
for (int i = 1; i < values.size(); i++) {
if (values.get(i) - values.get(i - 1) != 1) {
return false;
}
}
return true;
}
/** Describe a single value in the field's own vocabulary. */
private static String singleValue(int n, FieldMeta meta) {
return switch (meta.name()) {
case MINUTE -> "minute " + n;
case HOUR -> "hour " + n;
case DAY_OF_MONTH -> "day " + n + " of the month";
case MONTH -> monthName(n);
case DAY_OF_WEEK -> dowName(n);
};
}
/**
* Describe a parsed field as a human phrase (no leading preposition).
* {@code raw} distinguishes step syntax (star/N or A-B/N) from plain lists,
* since two different raw forms can expand to the same value set.
*/
private static String describeField(ParsedField p) {
FieldMeta meta = p.meta();
List<Integer> values = p.values();
if (p.wildcard()) {
return switch (meta.name()) {
case MINUTE -> "every minute";
case HOUR -> "every hour";
case DAY_OF_MONTH -> "every day of the month";
case MONTH -> "every month";
case DAY_OF_WEEK -> "every day of the week";
};
}
// Step syntax is reported as "every N <units>".
int slashIdx = p.raw().indexOf('/');
if (slashIdx != -1 && !values.isEmpty()) {
int step;
try {
step = parseIntStrict(p.raw().substring(slashIdx + 1), meta.name().label());
} catch (CronException e) {
step = 1; // multi-term raw ("*/5,10-20/3") — fall back like the Rust port
}
int start = values.get(0);
String unitPlural = switch (meta.name()) {
case DAY_OF_MONTH -> "days of the month";
case DAY_OF_WEEK -> "days of the week";
default -> meta.name().label() + "s";
};
if (start == meta.min()) {
return "every " + step + " " + unitPlural;
}
return "every " + step + " " + unitPlural + " starting at " + singleValue(start, meta);
}
if (values.size() == 1) {
return singleValue(values.get(0), meta);
}
if (isContiguous(values)) {
int a = values.get(0);
int b = values.get(values.size() - 1);
if (meta.name() == FieldName.MONTH) {
return monthName(a) + " through " + monthName(b);
}
if (meta.name() == FieldName.DAY_OF_WEEK) {
return dowName(a) + " through " + dowName(b);
}
String unitPlural = meta.name() == FieldName.DAY_OF_MONTH ? "days" : meta.name().label() + "s";
return unitPlural + " " + a + " through " + b;
}
// Explicit list of discrete values.
StringBuilder joined = new StringBuilder();
for (int i = 0; i < values.size(); i++) {
if (i > 0) {
joined.append(", ");
}
joined.append(values.get(i));
}
return switch (meta.name()) {
case MONTH -> joinNames(values, true);
case DAY_OF_WEEK -> joinNames(values, false);
case MINUTE -> "minutes " + joined;
case HOUR -> "hours " + joined;
case DAY_OF_MONTH -> "days " + joined + " of the month";
};
}
private static String joinNames(List<Integer> values, boolean months) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < values.size(); i++) {
if (i > 0) {
sb.append(", ");
}
sb.append(months ? monthName(values.get(i)) : dowName(values.get(i)));
}
return sb.toString();
}
/**
* Prepend a preposition, but never before a phrase that already leads with
* "every" (e.g. "every day of the week" reads wrong as "on every ...").
*/
private static String prepend(String prefix, String phrase) {
return phrase.startsWith("every") ? phrase : prefix + " " + phrase;
}
/** Compose the opening time-of-day clause from the minute and hour fields. */
private static String timeClause(ParsedField minute, ParsedField hour) {
boolean mAll = minute.wildcard();
boolean hAll = hour.wildcard();
boolean mSingle = !mAll && minute.values().size() == 1;
boolean hSingle = !hAll && hour.values().size() == 1;
if (mAll && hAll) {
return "Every minute";
}
if (mAll && hSingle) {
return "Every minute of hour " + hour.values().get(0);
}
if (mSingle && hAll) {
return "At minute " + minute.values().get(0) + " of every hour";
}
if (mSingle && hSingle) {
return "At " + pad2(hour.values().get(0)) + ":" + pad2(minute.values().get(0));
}
// Mixed: describe each non-wildcard field, hour first.
List<String> clauses = new ArrayList<>();
if (!hAll) {
clauses.add(describeField(hour));
}
if (!mAll) {
clauses.add(describeField(minute));
}
String s = String.join(", ", clauses);
return Character.toUpperCase(s.charAt(0)) + s.substring(1);
}
private static String composeDescription(List<ParsedField> parts) {
ParsedField minute = parts.get(0);
ParsedField hour = parts.get(1);
ParsedField dom = parts.get(2);
ParsedField month = parts.get(3);
ParsedField dow = parts.get(4);
List<String> clauses = new ArrayList<>();
clauses.add(timeClause(minute, hour));
if (!dom.wildcard()) {
clauses.add(prepend("on", describeField(dom)));
}
if (!month.wildcard()) {
clauses.add(prepend("in", describeField(month)));
}
if (!dow.wildcard()) {
clauses.add(prepend("on", describeField(dow)));
}
return String.join(", ", clauses);
}
// ─── Public API ──────────────────────────────────────────────────────────
/** One entry of the per-field explanation. */
public record CronFieldInfo(String field, String value, String meaning) {
}
/** The result of {@link #explainCron(String)}. */
public record CronExplanation(
boolean valid,
String description, // "" when invalid
List<CronFieldInfo> fields, // one per field; empty when invalid
String error) { // present only when valid is false
static CronExplanation invalid(String error) {
return new CronExplanation(false, "", List.of(), error);
}
}
/**
* Parse and explain a 5-field cron expression in plain English.
*
* <pre>{@code
* CronExplainer.explainCron("30 14 * * *").description(); // "At 14:30"
* }</pre>
*/
public static CronExplanation explainCron(String expr) {
try {
List<ParsedField> parts = parseExpr(expr);
List<CronFieldInfo> fields = new ArrayList<>(5);
for (ParsedField p : parts) {
fields.add(new CronFieldInfo(p.meta().name().label(), p.raw(), describeField(p)));
}
return new CronExplanation(true, composeDescription(parts), List.copyOf(fields), null);
} catch (CronException e) {
return CronExplanation.invalid(e.getMessage());
}
}
/** Per-field specs for {@link #buildCron(BuildCronOptions)}. Null/empty fields default to {@code *}. */
public static final class BuildCronOptions {
public String minute;
public String hour;
public String dom;
public String month;
public String dow;
public BuildCronOptions() {
}
public BuildCronOptions(String minute, String hour, String dom, String month, String dow) {
this.minute = minute;
this.hour = hour;
this.dom = dom;
this.month = month;
this.dow = dow;
}
}
/**
* Assemble a 5-field cron expression from per-field specs. Each field
* defaults to {@code *} when empty/omitted; invalid fields throw
* {@link CronException} so callers cannot build a malformed expression.
*
* <pre>{@code
* buildCron(new BuildCronOptions("30", "14", null, null, null)); // "30 14 * * *"
* }</pre>
*/
public static String buildCron(BuildCronOptions opts) {
String[] values = {opts.minute, opts.hour, opts.dom, opts.month, opts.dow};
List<String> out = new ArrayList<>(5);
for (int i = 0; i < 5; i++) {
String v = values[i] == null ? "" : values[i].trim();
if (v.isEmpty()) {
out.add("*");
continue;
}
expandField(v, FIELDS[i]); // validates; throws on bad input
out.add(v);
}
return String.join(" ", out);
}
/**
* Next time the expression fires, strictly after {@code after}, evaluated
* as UTC civil time (LocalDateTime carries no zone; the caller controls
* the instant).
*
* <p>Implements standard Vixie-cron day matching: when BOTH day-of-month
* and day-of-week are restricted, a match on either suffices (OR);
* otherwise both must match (AND). Returns null if no firing occurs
* within ~3 years.
*/
public static LocalDateTime nextRun(String expr, LocalDateTime after) {
List<ParsedField> parts;
try {
parts = parseExpr(expr);
} catch (CronException e) {
return null;
}
Set<Integer> mSet = new HashSet<>(parts.get(0).values());
Set<Integer> hSet = new HashSet<>(parts.get(1).values());
Set<Integer> domSet = new HashSet<>(parts.get(2).values());
Set<Integer> monSet = new HashSet<>(parts.get(3).values());
Set<Integer> dowSet = new HashSet<>(parts.get(4).values());
boolean domWild = parts.get(2).wildcard();
boolean dowWild = parts.get(4).wildcard();
// Start at the top of the minute following `after`, seconds zeroed.
LocalDateTime cur = after.withSecond(0).withNano(0).plusMinutes(1);
int limit = cur.getYear() + 3; // hard stop ~3 years out
while (cur.getYear() < limit) {
if (!monSet.contains(cur.getMonthValue())) {
// Advance to day 1 of next month, midnight.
cur = cur.getMonthValue() == 12
? LocalDateTime.of(cur.getYear() + 1, 1, 1, 0, 0)
: LocalDateTime.of(cur.getYear(), cur.getMonthValue() + 1, 1, 0, 0);
continue;
}
boolean domOk = domSet.contains(cur.getDayOfMonth());
// DayOfWeek.getValue() is 1=Monday..7=Sunday; cron wants 0=Sunday..6=Saturday,
// so mod 7 aligns Sunday with 0 (same rotation as the Python port).
int cronDow = cur.getDayOfWeek().getValue() % 7;
boolean dowOk = dowSet.contains(cronDow);
boolean dayOk = domWild || dowWild ? domOk && dowOk : domOk || dowOk;
if (!dayOk) {
cur = cur.toLocalDate().plusDays(1).atStartOfDay();
continue;
}
if (!hSet.contains(cur.getHour())) {
cur = cur.toLocalDate().atTime(cur.getHour(), 0).plusHours(1);
continue;
}
if (!mSet.contains(cur.getMinute())) {
cur = cur.plusMinutes(1);
continue;
}
return cur;
}
return null;
}
}
Also available in 13 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →