Skip to content

Cron Expression Explainer — Kotlin 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 Kotlin implementation — the same logic the interactive tool runs, in a shareable, citable form.

// cron-explainer — 5-field cron parser, plain-English explainer, builder, and
// next-run calculator — Kotlin polyglot showcase port.
//
// Language: Kotlin (1.9+, JVM; java.time, 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.

import java.time.LocalDateTime
import java.util.Locale

/** Raised for a malformed field (the Python port's CronError is a ValueError). */
class CronException(message: String) : IllegalArgumentException(message)

/** Positional name of a cron field. */
enum class FieldName(val label: String) {
    MINUTE("minute"),
    HOUR("hour"),
    DAY_OF_MONTH("day-of-month"),
    MONTH("month"),
    DAY_OF_WEEK("day-of-week"),
}

/** Per-field metadata: numeric range plus parsing rules. */
internal data class FieldMeta(
    val name: FieldName,
    val min: Int,
    val max: Int,
    val named: Boolean,
    val wrapMax: Boolean,
)

/** The positional field table, indexed 0..4. */
private val FIELDS = listOf(
    FieldMeta(FieldName.MINUTE, 0, 59, named = false, wrapMax = false),
    FieldMeta(FieldName.HOUR, 0, 23, named = false, wrapMax = false),
    FieldMeta(FieldName.DAY_OF_MONTH, 1, 31, named = false, wrapMax = false),
    FieldMeta(FieldName.MONTH, 1, 12, named = true, wrapMax = false),
    FieldMeta(FieldName.DAY_OF_WEEK, 0, 7, named = true, wrapMax = true),
)

private val MONTH_NAMES = listOf(
    "January", "February", "March", "April", "May", "June",
    "July", "August", "September", "October", "November", "December",
)
private val DOW_NAMES = listOf(
    "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 val MONTH_TOKENS = listOf(
    "JAN" to 1, "FEB" to 2, "MAR" to 3, "APR" to 4, "MAY" to 5, "JUN" to 6,
    "JUL" to 7, "AUG" to 8, "SEP" to 9, "OCT" to 10, "NOV" to 11, "DEC" to 12,
)
private val DOW_TOKENS = listOf(
    "SUN" to 0, "MON" to 1, "TUE" to 2, "WED" to 3, "THU" to 4, "FRI" to 5, "SAT" to 6,
)

private fun pad2(n: Int): String = if (n < 10) "0$n" else n.toString()

private fun monthName(m: Int): String = MONTH_NAMES[m - 1]

private fun dowName(d: Int): String = DOW_NAMES[d % 7]

/** Inclusive integer range, e.g. inclusiveRange(1, 5) → [1, 2, 3, 4, 5]. */
private fun inclusiveRange(lo: Int, hi: Int): List<Int> = (lo..hi).toList()

/**
 * Parse a strictly-numeric token (ASCII digits only). Rejects named tokens,
 * signs, and surrounding garbage so malformed fields surface clearly.
 */
private fun parseIntStrict(s: String, label: String): Int {
    val t = s.trim()
    if (t.isEmpty() || t.any { it !in '0'..'9' }) {
        throw CronException("$label: invalid number \"$s\"")
    }
    return t.toInt()
}

/**
 * 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 fun normalize(value: String, meta: FieldMeta): String {
    var v = value.trim().uppercase(Locale.ROOT)
    if (!meta.named) return v
    val tokens = if (meta.name == FieldName.MONTH) MONTH_TOKENS else DOW_TOKENS
    for ((token, num) in tokens) {
        v = v.replace(token, num.toString())
    }
    return v
}

/** A field after expansion: the matched values plus the raw token and a flag
 * distinguishing a bare `*` (wildcard) from an explicit enumeration. */
internal data class ParsedField(
    val meta: FieldMeta,
    val raw: String,
    val values: List<Int>,
    val wildcard: Boolean,
)

/**
 * Expand one field value into the explicit set of numbers it matches.
 *
 * Handles `*`, `* / N`, `A-B`, `A-B/N`, `A` (single), `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 `*`.
 */
internal fun expandField(value: String, meta: FieldMeta): ParsedField {
    val norm = normalize(value, meta)
    if (norm.isEmpty()) throw CronException("${meta.name.label}: empty field")
    if (norm == "*") {
        return ParsedField(meta, value, inclusiveRange(meta.min, meta.max), wildcard = true)
    }

    val set = mutableListOf<Int>()
    for (term in norm.split(',')) {
        if (term.isEmpty()) throw CronException("${meta.name.label}: empty list item")

        val slashIdx = term.indexOf('/')
        var base = term
        var step = 1
        if (slashIdx != -1) {
            base = term.substring(0, slashIdx)
            step = parseIntStrict(term.substring(slashIdx + 1), meta.name.label)
            if (step <= 0) throw CronException("${meta.name.label}: step must be a positive number")
        }

        val lo: Int
        val hi: Int
        if (base == "*") {
            lo = meta.min
            hi = meta.max
        } else {
            val 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 = if (slashIdx != -1) meta.max else lo
            }
        }

        if (lo > hi) throw CronException("${meta.name.label}: range start $lo is greater than end $hi")
        if (lo < meta.min) throw CronException("${meta.name.label}: value $lo is below minimum ${meta.min}")
        if (hi > meta.max) throw CronException("${meta.name.label}: value $hi is above maximum ${meta.max}")

        var v = lo
        while (v <= hi) {
            val resolved = if (meta.wrapMax && v == meta.max) meta.min else v
            if (!set.contains(resolved)) set.add(resolved)
            v += step
        }
    }

    return ParsedField(meta, value, set.distinct().sorted(), wildcard = false)
}

/** Parse all five fields, or throw a [CronException] carrying the
 * human-readable error. */
internal fun parseExpr(expr: String): List<ParsedField> {
    val tokens = expr.trim().split(Regex("\\s+")).filter { it.isNotEmpty() }
    if (tokens.size != 5) {
        throw CronException(
            "Expected 5 fields (minute hour day-of-month month day-of-week), got ${tokens.size}"
        )
    }
    return (0 until 5).map { expandField(tokens[it], FIELDS[it]) }
}

/** True when a sorted value list is a contiguous run (e.g. [3, 4, 5, 6]). */
private fun isContiguous(values: List<Int>): Boolean =
    values.zipWithNext().all { (a, b) -> b - a == 1 }

/** Describe a single value in the field's own vocabulary. */
private fun singleValue(n: Int, meta: FieldMeta): String = when (meta.name) {
    FieldName.MINUTE -> "minute $n"
    FieldName.HOUR -> "hour $n"
    FieldName.DAY_OF_MONTH -> "day $n of the month"
    FieldName.MONTH -> monthName(n)
    FieldName.DAY_OF_WEEK -> dowName(n)
}

/**
 * Describe a parsed field as a human phrase (no leading preposition). `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.
 */
internal fun describeField(p: ParsedField): String {
    val meta = p.meta
    val values = p.values

    if (p.wildcard) {
        return when (meta.name) {
            FieldName.MINUTE -> "every minute"
            FieldName.HOUR -> "every hour"
            FieldName.DAY_OF_MONTH -> "every day of the month"
            FieldName.MONTH -> "every month"
            FieldName.DAY_OF_WEEK -> "every day of the week"
        }
    }

    // Step syntax is reported as "every N <units>".
    val slashIdx = p.raw.indexOf('/')
    if (slashIdx != -1 && values.isNotEmpty()) {
        val step = try {
            parseIntStrict(p.raw.substring(slashIdx + 1), meta.name.label)
        } catch (e: CronException) {
            1 // multi-term raw ("*/5,10-20/3") — fall back like the Rust port
        }
        val start = values.first()
        val unitPlural = when (meta.name) {
            FieldName.DAY_OF_MONTH -> "days of the month"
            FieldName.DAY_OF_WEEK -> "days of the week"
            else -> "${meta.name.label}s"
        }
        return if (start == meta.min) {
            "every $step $unitPlural"
        } else {
            "every $step $unitPlural starting at ${singleValue(start, meta)}"
        }
    }

    if (values.size == 1) return singleValue(values.first(), meta)

    if (isContiguous(values)) {
        val a = values.first()
        val b = values.last()
        if (meta.name == FieldName.MONTH) return "${monthName(a)} through ${monthName(b)}"
        if (meta.name == FieldName.DAY_OF_WEEK) return "${dowName(a)} through ${dowName(b)}"
        val unitPlural = if (meta.name == FieldName.DAY_OF_MONTH) "days" else "${meta.name.label}s"
        return "$unitPlural $a through $b"
    }

    // Explicit list of discrete values.
    val joined = values.joinToString(", ")
    return when (meta.name) {
        FieldName.MONTH -> values.joinToString(", ") { monthName(it) }
        FieldName.DAY_OF_WEEK -> values.joinToString(", ") { dowName(it) }
        FieldName.MINUTE -> "minutes $joined"
        FieldName.HOUR -> "hours $joined"
        FieldName.DAY_OF_MONTH -> "days $joined of the month"
    }
}

/**
 * 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 fun prepend(prefix: String, phrase: String): String =
    if (phrase.startsWith("every")) phrase else "$prefix $phrase"

/** Compose the opening time-of-day clause from the minute and hour fields. */
private fun timeClause(minute: ParsedField, hour: ParsedField): String {
    val mAll = minute.wildcard
    val hAll = hour.wildcard
    val mSingle = !mAll && minute.values.size == 1
    val hSingle = !hAll && hour.values.size == 1

    if (mAll && hAll) return "Every minute"
    if (mAll && hSingle) return "Every minute of hour ${hour.values.first()}"
    if (mSingle && hAll) return "At minute ${minute.values.first()} of every hour"
    if (mSingle && hSingle) {
        return "At ${pad2(hour.values.first())}:${pad2(minute.values.first())}"
    }

    // Mixed: describe each non-wildcard field, hour first.
    val clauses = buildList {
        if (!hAll) add(describeField(hour))
        if (!mAll) add(describeField(minute))
    }
    val s = clauses.joinToString(", ")
    return s.replaceFirstChar { it.uppercase(Locale.ROOT) }
}

private fun composeDescription(parts: List<ParsedField>): String {
    val (minute, hour, dom, month, dow) = parts
    return buildList {
        add(timeClause(minute, hour))
        if (!dom.wildcard) add(prepend("on", describeField(dom)))
        if (!month.wildcard) add(prepend("in", describeField(month)))
        if (!dow.wildcard) add(prepend("on", describeField(dow)))
    }.joinToString(", ")
}

// ─── Public API ─────────────────────────────────────────────────────────────

/** One entry of the per-field explanation. */
data class CronFieldInfo(
    val field: String,  // one of the five positional field names
    val value: String,  // raw field value as written in the expression
    val meaning: String, // human-readable description of what this field matches
)

/** The result of [explainCron]. */
data class CronExplanation(
    val valid: Boolean,
    val description: String,          // "" when invalid
    val fields: List<CronFieldInfo>,  // one per field; empty when invalid
    val error: String?,               // present only when valid is false
)

/**
 * Parse and explain a 5-field cron expression in plain English.
 *
 * ```kotlin
 * explainCron("30 14 * * *").description // At 14:30
 * ```
 */
fun explainCron(expr: String): CronExplanation =
    try {
        val parts = parseExpr(expr)
        CronExplanation(
            valid = true,
            description = composeDescription(parts),
            fields = parts.map { CronFieldInfo(it.meta.name.label, it.raw, describeField(it)) },
            error = null,
        )
    } catch (e: CronException) {
        CronExplanation(valid = false, description = "", fields = emptyList(), error = e.message)
    }

/** Per-field specs for [buildCron]. Null/empty fields default to `*`. */
data class BuildCronOptions(
    var minute: String? = null,
    var hour: String? = null,
    var dom: String? = null,
    var month: String? = null,
    var dow: String? = null,
)

/**
 * Assemble a 5-field cron expression from per-field specs. Each field
 * defaults to `*` when empty/omitted; invalid fields throw [CronException]
 * so callers cannot build a malformed expression.
 *
 * ```kotlin
 * buildCron(BuildCronOptions(minute = "30", hour = "14")) // 30 14 * * *
 * ```
 */
fun buildCron(opts: BuildCronOptions = BuildCronOptions()): String {
    val specs = listOf(
        FIELDS[0] to opts.minute,
        FIELDS[1] to opts.hour,
        FIELDS[2] to opts.dom,
        FIELDS[3] to opts.month,
        FIELDS[4] to opts.dow,
    )
    return specs.joinToString(" ") { (meta, value) ->
        val v = value?.trim().orEmpty()
        if (v.isEmpty()) {
            "*"
        } else {
            expandField(v, meta) // validates; throws on bad input
            v
        }
    }
}

/**
 * Next time the expression fires, strictly after `after`, evaluated as UTC
 * civil time (LocalDateTime carries no zone; the caller controls the
 * instant).
 *
 * 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.
 */
fun nextRun(expr: String, after: LocalDateTime): LocalDateTime? {
    val parts = try {
        parseExpr(expr)
    } catch (e: CronException) {
        return null
    }
    val (minute, hour, dom, month, dow) = parts
    val mSet = minute.values.toSet()
    val hSet = hour.values.toSet()
    val domSet = dom.values.toSet()
    val monSet = month.values.toSet()
    val dowSet = dow.values.toSet()
    val domWild = dom.wildcard
    val dowWild = dow.wildcard

    // Start at the top of the minute following `after`, seconds zeroed.
    var cur = after.withSecond(0).withNano(0).plusMinutes(1)
    val limit = cur.year + 3 // hard stop ~3 years out

    while (cur.year < limit) {
        if (cur.monthValue !in monSet) {
            // Advance to day 1 of next month, midnight.
            cur = if (cur.monthValue == 12) {
                LocalDateTime.of(cur.year + 1, 1, 1, 0, 0)
            } else {
                LocalDateTime.of(cur.year, cur.monthValue + 1, 1, 0, 0)
            }
            continue
        }
        val domOk = cur.dayOfMonth in domSet
        // DayOfWeek.value is 1=Monday..7=Sunday; cron wants 0=Sunday..6=Saturday,
        // so mod 7 aligns Sunday with 0 (same rotation as the Python port).
        val dowOk = cur.dayOfWeek.value % 7 in dowSet
        val dayOk = if (domWild || dowWild) domOk && dowOk else domOk || dowOk
        if (!dayOk) {
            cur = cur.toLocalDate().plusDays(1).atStartOfDay()
            continue
        }
        if (cur.hour !in hSet) {
            cur = cur.toLocalDate().atTime(cur.hour, 0).plusHours(1)
            continue
        }
        if (cur.minute !in mSet) {
            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 →