Skip to content

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 →