Skip to content

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 →