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 →