Cron Expression Explainer — Swift 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 Swift 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 — Swift polyglot showcase port.
//
// Language: Swift 5.9+ (Foundation 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 beyond Foundation. Deterministic. Times are interpreted as UTC so
// results are unambiguous and DST-independent (the caller controls the
// instant) — a fixed GMT gregorian calendar drives all date arithmetic.
//
// The public surface mirrors the TypeScript reference: explainCron,
// buildCron, nextRun.
import Foundation
// MARK: - 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.
enum FieldName: String {
case minute
case hour
case dayOfMonth = "day-of-month"
case month
case dayOfWeek = "day-of-week"
/// The human label used in error messages and pluralised descriptions.
var label: String { rawValue }
}
/// Per-field metadata: numeric range plus parsing rules.
struct FieldMeta: Sendable {
let name: FieldName
let min: Int
let max: Int
let named: Bool
let wrapMax: Bool
}
/// The positional field table, indexed 0..4.
let cronFields: [FieldMeta] = [
FieldMeta(name: .minute, min: 0, max: 59, named: false, wrapMax: false),
FieldMeta(name: .hour, min: 0, max: 23, named: false, wrapMax: false),
FieldMeta(name: .dayOfMonth, min: 1, max: 31, named: false, wrapMax: false),
FieldMeta(name: .month, min: 1, max: 12, named: true, wrapMax: false),
FieldMeta(name: .dayOfWeek, min: 0, max: 7, named: true, wrapMax: true),
]
let monthNames = [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
]
let dowNames = [
"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.
let monthTokens: [(token: String, value: Int)] = [
("JAN", 1), ("FEB", 2), ("MAR", 3), ("APR", 4), ("MAY", 5), ("JUN", 6),
("JUL", 7), ("AUG", 8), ("SEP", 9), ("OCT", 10), ("NOV", 11), ("DEC", 12),
]
let dowTokens: [(token: String, value: Int)] = [
("SUN", 0), ("MON", 1), ("TUE", 2), ("WED", 3), ("THU", 4), ("FRI", 5), ("SAT", 6),
]
/// A malformed field (the Python port's CronError is a ValueError subclass).
struct CronError: Error {
let message: String
}
private func pad2(_ n: Int) -> String {
n < 10 ? "0\(n)" : "\(n)"
}
private func monthName(_ m: Int) -> String {
monthNames[m - 1]
}
private func dowName(_ d: Int) -> String {
dowNames[d % 7]
}
/// Inclusive integer range, e.g. inclusiveRange(1, 5) -> [1, 2, 3, 4, 5].
private func inclusiveRange(_ lo: Int, _ hi: Int) -> [Int] {
Array(lo...hi)
}
/// Parse a strictly-numeric token (ASCII digits only). Rejects named tokens,
/// signs, and surrounding garbage so malformed fields surface clearly.
private func parseIntStrict(_ s: String, _ label: String) throws -> Int {
let t = s.trimmingCharacters(in: .whitespaces)
guard !t.isEmpty, t.allSatisfy({ $0.isASCII && $0.isNumber }), let n = Int(t) else {
throw CronError(message: "\(label): invalid number \"\(s)\"")
}
return n
}
/// 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 func normalize(_ value: String, _ meta: FieldMeta) -> String {
var v = value.trimmingCharacters(in: .whitespaces).uppercased()
guard meta.named else { return v }
let tokens = meta.name == .month ? monthTokens : dowTokens
for pair in tokens {
v = v.replacingOccurrences(of: pair.token, with: "\(pair.value)")
}
return v
}
/// A field after expansion: the matched values plus the raw token and a flag
/// distinguishing a bare `*` (wildcard) from an explicit enumeration.
struct ParsedField {
let meta: FieldMeta
let raw: String
let values: [Int]
let wildcard: Bool
}
/// 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 `*`.
func expandField(_ value: String, _ meta: FieldMeta) throws -> ParsedField {
let norm = normalize(value, meta)
if norm.isEmpty {
throw CronError(message: "\(meta.name.label): empty field")
}
if norm == "*" {
return ParsedField(meta: meta, raw: value, values: inclusiveRange(meta.min, meta.max), wildcard: true)
}
var set: [Int] = []
for term in norm.split(separator: ",", omittingEmptySubsequences: false).map(String.init) {
if term.isEmpty {
throw CronError(message: "\(meta.name.label): empty list item")
}
var base = term
var step = 1
if let slashIdx = term.firstIndex(of: "/") {
base = String(term[..<slashIdx])
step = try parseIntStrict(String(term[term.index(after: slashIdx)...]), meta.name.label)
if step <= 0 {
throw CronError(message: "\(meta.name.label): step must be a positive number")
}
}
var lo: Int
var hi: Int
if base == "*" {
lo = meta.min
hi = meta.max
} else if let dash = base.firstIndex(of: "-") {
lo = try parseIntStrict(String(base[..<dash]), meta.name.label)
hi = try parseIntStrict(String(base[base.index(after: dash)...]), meta.name.label)
} else {
lo = try parseIntStrict(base, meta.name.label)
// "A/step" runs from A to the field max; a bare "A" is a single value.
hi = term.contains("/") ? meta.max : lo
}
if lo > hi {
throw CronError(message: "\(meta.name.label): range start \(lo) is greater than end \(hi)")
}
if lo < meta.min {
throw CronError(message: "\(meta.name.label): value \(lo) is below minimum \(meta.min)")
}
if hi > meta.max {
throw CronError(message: "\(meta.name.label): value \(hi) is above maximum \(meta.max)")
}
var v = lo
while v <= hi {
let resolved = meta.wrapMax && v == meta.max ? meta.min : v
if !set.contains(resolved) {
set.append(resolved)
}
v += step
}
}
return ParsedField(meta: meta, raw: value, values: set.sorted(), wildcard: false)
}
/// Parse all five fields, or throw a `CronError` carrying the human-readable
/// error.
func parseExpr(_ expr: String) throws -> [ParsedField] {
let tokens = expr.split(whereSeparator: { $0.isWhitespace }).map(String.init)
if tokens.count != 5 {
throw CronError(
message: "Expected 5 fields (minute hour day-of-month month day-of-week), got \(tokens.count)")
}
return try (0..<5).map { try expandField(tokens[$0], cronFields[$0]) }
}
/// True when a sorted value list is a contiguous run (e.g. [3, 4, 5, 6]).
private func isContiguous(_ values: [Int]) -> Bool {
zip(values, values.dropFirst()).allSatisfy { $1 - $0 == 1 }
}
/// Describe a single value in the field's own vocabulary.
private func singleValue(_ n: Int, _ meta: FieldMeta) -> String {
switch meta.name {
case .minute: return "minute \(n)"
case .hour: return "hour \(n)"
case .dayOfMonth: return "day \(n) of the month"
case .month: return monthName(n)
case .dayOfWeek: return 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.
func describeField(_ p: ParsedField) -> String {
let meta = p.meta
let values = p.values
if p.wildcard {
switch meta.name {
case .minute: return "every minute"
case .hour: return "every hour"
case .dayOfMonth: return "every day of the month"
case .month: return "every month"
case .dayOfWeek: return "every day of the week"
}
}
// Step syntax is reported as "every N <units>".
if let slashIdx = p.raw.firstIndex(of: "/"), !values.isEmpty {
let step = (try? parseIntStrict(String(p.raw[p.raw.index(after: slashIdx)...]), meta.name.label)) ?? 1
let start = values[0]
let unitPlural: String
switch meta.name {
case .dayOfMonth: unitPlural = "days of the month"
case .dayOfWeek: unitPlural = "days of the week"
default: unitPlural = "\(meta.name.label)s"
}
if start == meta.min {
return "every \(step) \(unitPlural)"
}
return "every \(step) \(unitPlural) starting at \(singleValue(start, meta))"
}
if values.count == 1 {
return singleValue(values[0], meta)
}
if isContiguous(values) {
let a = values.first!
let b = values.last!
if meta.name == .month {
return "\(monthName(a)) through \(monthName(b))"
}
if meta.name == .dayOfWeek {
return "\(dowName(a)) through \(dowName(b))"
}
let unitPlural = meta.name == .dayOfMonth ? "days" : "\(meta.name.label)s"
return "\(unitPlural) \(a) through \(b)"
}
// Explicit list of discrete values.
let joined = values.map(String.init).joined(separator: ", ")
switch meta.name {
case .month: return values.map(monthName).joined(separator: ", ")
case .dayOfWeek: return values.map(dowName).joined(separator: ", ")
case .minute: return "minutes \(joined)"
case .hour: return "hours \(joined)"
case .dayOfMonth: return "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 func prepend(_ prefix: String, _ phrase: String) -> String {
phrase.hasPrefix("every") ? phrase : "\(prefix) \(phrase)"
}
/// Compose the opening time-of-day clause from the minute and hour fields.
private func timeClause(_ minute: ParsedField, _ hour: ParsedField) -> String {
let mAll = minute.wildcard
let hAll = hour.wildcard
let mSingle = !mAll && minute.values.count == 1
let hSingle = !hAll && hour.values.count == 1
if mAll && hAll {
return "Every minute"
}
if mAll && hSingle {
return "Every minute of hour \(hour.values[0])"
}
if mSingle && hAll {
return "At minute \(minute.values[0]) of every hour"
}
if mSingle && hSingle {
return "At \(pad2(hour.values[0])):\(pad2(minute.values[0]))"
}
// Mixed: describe each non-wildcard field, hour first.
var clauses: [String] = []
if !hAll {
clauses.append(describeField(hour))
}
if !mAll {
clauses.append(describeField(minute))
}
let s = clauses.joined(separator: ", ")
return s.prefix(1).uppercased() + s.dropFirst()
}
private func composeDescription(_ parts: [ParsedField]) -> String {
let minute = parts[0], hour = parts[1], dom = parts[2], month = parts[3], dow = parts[4]
var clauses = [timeClause(minute, hour)]
if !dom.wildcard {
clauses.append(prepend("on", describeField(dom)))
}
if !month.wildcard {
clauses.append(prepend("in", describeField(month)))
}
if !dow.wildcard {
clauses.append(prepend("on", describeField(dow)))
}
return clauses.joined(separator: ", ")
}
// MARK: - Public API
/// One entry of the per-field explanation.
struct CronFieldInfo: Equatable, Sendable {
let field: String // one of the five positional field names
let value: String // raw field value as written in the expression
let meaning: String // human-readable description of what this field matches
}
/// The result of `explainCron`.
struct CronExplanation: Equatable, Sendable {
let valid: Bool
let description: String // "" when invalid
let fields: [CronFieldInfo] // one per field; empty when invalid
let error: String? // present only when valid is false
}
/// Parse and explain a 5-field cron expression in plain English.
///
/// explainCron("30 14 * * *").description // "At 14:30"
func explainCron(_ expr: String) -> CronExplanation {
do {
let parts = try parseExpr(expr)
let fields = parts.map {
CronFieldInfo(field: $0.meta.name.label, value: $0.raw, meaning: describeField($0))
}
return CronExplanation(
valid: true, description: composeDescription(parts), fields: fields, error: nil)
} catch let e as CronError {
return CronExplanation(valid: false, description: "", fields: [], error: e.message)
} catch {
return CronExplanation(valid: false, description: "", fields: [], error: "\(error)")
}
}
/// Per-field specs for `buildCron`. Nil/empty fields default to `*`.
struct BuildCronOptions {
var minute: String? = nil
var hour: String? = nil
var dom: String? = nil
var month: String? = nil
var dow: String? = nil
}
/// Assemble a 5-field cron expression from per-field specs. Each field
/// defaults to `*` when empty/omitted; invalid fields throw `CronError` so
/// callers cannot build a malformed expression.
///
/// try buildCron(BuildCronOptions(minute: "30", hour: "14")) // "30 14 * * *"
func buildCron(_ opts: BuildCronOptions = BuildCronOptions()) throws -> String {
let specs: [(FieldMeta, String?)] = [
(cronFields[0], opts.minute),
(cronFields[1], opts.hour),
(cronFields[2], opts.dom),
(cronFields[3], opts.month),
(cronFields[4], opts.dow),
]
var out: [String] = []
for (meta, value) in specs {
let v = (value ?? "").trimmingCharacters(in: .whitespaces)
if v.isEmpty {
out.append("*")
continue
}
_ = try expandField(v, meta) // validates; throws on bad input
out.append(v)
}
return out.joined(separator: " ")
}
// MARK: - Next-run scanning
/// A fixed gregorian calendar pinned to GMT — every date arithmetic step in
/// `nextRun` runs against it, so results are UTC and DST-independent.
private var utcCalendar: Calendar = {
var cal = Calendar(identifier: .gregorian)
cal.timeZone = TimeZone(secondsFromGMT: 0)!
return cal
}()
/// Next time the expression fires, strictly after `after`, evaluated in UTC.
///
/// 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 nil if no firing occurs within ~3 years.
func nextRun(_ expr: String, after: Date) -> Date? {
guard let parts = try? parseExpr(expr) else { return nil }
let minute = parts[0], hour = parts[1], dom = parts[2], month = parts[3], dow = parts[4]
let mSet = Set(minute.values)
let hSet = Set(hour.values)
let domSet = Set(dom.values)
let monSet = Set(month.values)
let dowSet = Set(dow.values)
let domWild = dom.wildcard
let dowWild = dow.wildcard
let cal = utcCalendar
// Start at the top of the minute following `after`, seconds zeroed.
var comps = cal.dateComponents([.year, .month, .day, .hour, .minute], from: after)
comps.second = 0
comps.nanosecond = 0
guard var cur = cal.date(from: comps) else { return nil }
cur.addTimeInterval(60)
let limit = cal.component(.year, from: cur) + 3 // hard stop ~3 years out
while cal.component(.year, from: cur) < limit {
let curMonth = cal.component(.month, from: cur)
if !monSet.contains(curMonth) {
// Advance to day 1 of next month, midnight.
let curYear = cal.component(.year, from: cur)
var next = DateComponents()
next.year = curMonth == 12 ? curYear + 1 : curYear
next.month = curMonth == 12 ? 1 : curMonth + 1
next.day = 1
if let d = cal.date(from: next) {
cur = d
}
continue
}
let curDay = cal.component(.day, from: cur)
// Calendar weekday is 1=Sunday..7=Saturday; cron wants 0=Sunday..6=Saturday.
let cronDow = cal.component(.weekday, from: cur) - 1
let domOk = domSet.contains(curDay)
let dowOk = dowSet.contains(cronDow)
let dayOk = domWild || dowWild ? domOk && dowOk : domOk || dowOk
if !dayOk {
cur = cal.startOfDay(for: cur).addingTimeInterval(86_400)
continue
}
let curHour = cal.component(.hour, from: cur)
if !hSet.contains(curHour) {
// Next hour with the minute zeroed; hour 23 rolls to next midnight.
if curHour == 23 {
cur = cal.startOfDay(for: cur).addingTimeInterval(86_400)
} else {
var hc = cal.dateComponents([.year, .month, .day], from: cur)
hc.hour = curHour + 1
hc.minute = 0
if let d = cal.date(from: hc) {
cur = d
}
}
continue
}
if !mSet.contains(cal.component(.minute, from: cur)) {
cur.addTimeInterval(60)
continue
}
return cur
}
return nil
}
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 →