Skip to content

Cron Expression Explainer — Zig 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 Zig 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 — Zig polyglot showcase port.
//!
//! Language: Zig 0.13 (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).
//!
//! Date handling: Zig's std.time covers epochs but ships no civil-calendar
//! conversion, so — like the Rust sibling port — this showcase works against a
//! small `CivilTime` value object plus Howard Hinnant's proleptic-Gregorian
//! serial-day algorithms (`daysFromCivil` / `civilFromDays`). All arithmetic
//! is plain i64; field overflow rolls over exactly like the JS reference's
//! setUTC* family.
//!
//! The public surface mirrors the TypeScript reference: `explainCron`,
//! `buildCron`, `nextRun`. The parser and renderer allocate many small
//! strings, so every function takes an explicit allocator — pass an arena and
//! reclaim everything in one `deinit`, the idiomatic pattern for a
//! parse-and-render pipeline.
//!
//!     var arena = std.heap.ArenaAllocator.init(gpa);
//!     defer arena.deinit();
//!     const a = arena.allocator();
//!     const e = try explainCron(a, "30 14 * * *");
//!     try std.io.getStdOut().writer().print("{s}\n", .{e.description});

const std = @import("std");
const Allocator = std.mem.Allocator;

/// Failure modes. `InvalidCron` carries a human-readable message in the
/// `err_msg` out-parameter threaded through the parsers and `buildCron`.
pub const Error = error{ InvalidCron, OutOfMemory };

// ─── Field model ────────────────────────────────────────────────────────────
//
// The five cron fields in positional order, each with its numeric range and
// whether it accepts named tokens (JAN..DEC / SUN..SAT). wrap_max is true
// only for day-of-week, where 7 is treated as an alias for 0 (Sunday).

/// Positional name of a cron field.
pub const FieldName = enum {
    minute,
    hour,
    day_of_month,
    month,
    day_of_week,

    /// The human label used in error messages and pluralised descriptions.
    pub fn label(self: FieldName) []const u8 {
        return switch (self) {
            .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.
pub const FieldMeta = struct {
    name: FieldName,
    min: i64,
    max: i64,
    named: bool,
    wrap_max: bool,
};

/// The positional field table, indexed 0..4.
pub const fields = [5]FieldMeta{
    .{ .name = .minute, .min = 0, .max = 59, .named = false, .wrap_max = false },
    .{ .name = .hour, .min = 0, .max = 23, .named = false, .wrap_max = false },
    .{ .name = .day_of_month, .min = 1, .max = 31, .named = false, .wrap_max = false },
    .{ .name = .month, .min = 1, .max = 12, .named = true, .wrap_max = false },
    .{ .name = .day_of_week, .min = 0, .max = 7, .named = true, .wrap_max = true },
};

const month_names = [12][]const u8{
    "January", "February", "March",     "April",   "May",      "June",
    "July",    "August",   "September", "October", "November", "December",
};
const dow_names = [7][]const u8{
    "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.
const TokenValue = struct { token: []const u8, value: i64 };
const month_tokens = [12]TokenValue{
    .{ .token = "JAN", .value = 1 },  .{ .token = "FEB", .value = 2 },
    .{ .token = "MAR", .value = 3 },  .{ .token = "APR", .value = 4 },
    .{ .token = "MAY", .value = 5 },  .{ .token = "JUN", .value = 6 },
    .{ .token = "JUL", .value = 7 },  .{ .token = "AUG", .value = 8 },
    .{ .token = "SEP", .value = 9 },  .{ .token = "OCT", .value = 10 },
    .{ .token = "NOV", .value = 11 }, .{ .token = "DEC", .value = 12 },
};
const dow_tokens = [7]TokenValue{
    .{ .token = "SUN", .value = 0 }, .{ .token = "MON", .value = 1 },
    .{ .token = "TUE", .value = 2 }, .{ .token = "WED", .value = 3 },
    .{ .token = "THU", .value = 4 }, .{ .token = "FRI", .value = 5 },
    .{ .token = "SAT", .value = 6 },
};

/// A field after expansion: the matched values plus the raw token and a flag
/// distinguishing a bare `*` (wildcard) from an explicit enumeration.
pub const ParsedField = struct {
    meta: FieldMeta,
    raw: []const u8,
    values: []i64,
    wildcard: bool,
};

// ─── Error plumbing ─────────────────────────────────────────────────────────

/// Record a formatted error message into the threaded out-parameter, then
/// signal `error.InvalidCron`.
fn fail(alloc: Allocator, err_msg: *?[]u8, comptime fmt: []const u8, args: anytype) Error {
    err_msg.* = std.fmt.allocPrint(alloc, fmt, args) catch return error.OutOfMemory;
    return error.InvalidCron;
}

fn upperCopy(alloc: Allocator, s: []const u8) Error![]u8 {
    const out = alloc.alloc(u8, s.len) catch return error.OutOfMemory;
    for (s, 0..) |c, i| out[i] = std.ascii.toUpper(c);
    return out;
}

/// Replace every occurrence of `needle` in `hay` with `repl`.
fn replaceAll(alloc: Allocator, hay: []const u8, needle: []const u8, repl: []const u8) Error![]u8 {
    const size = std.mem.replacementSize(u8, hay, needle, repl);
    const out = alloc.alloc(u8, size) catch return error.OutOfMemory;
    _ = std.mem.replace(u8, out, hay, needle, repl);
    return out;
}

fn pad2(alloc: Allocator, n: i64) Error![]u8 {
    return std.fmt.allocPrint(alloc, "{d:0>2}", .{n}) catch return error.OutOfMemory;
}

fn monthName(alloc: Allocator, m: i64) Error![]u8 {
    return std.fmt.allocPrint(alloc, "{s}", .{month_names[@intCast(m - 1)]}) catch return error.OutOfMemory;
}

fn dowName(alloc: Allocator, d: i64) Error![]u8 {
    return std.fmt.allocPrint(alloc, "{s}", .{dow_names[@intCast(@mod(d, 7))]}) catch return error.OutOfMemory;
}

/// Parse a strictly-numeric token (ASCII digits only). Rejects named tokens,
/// signs, and surrounding garbage so malformed fields surface clearly.
fn parseIntStrict(alloc: Allocator, s: []const u8, label: []const u8, err_msg: *?[]u8) Error!i64 {
    const t = std.mem.trim(u8, s, " \t\r\n");
    for (t) |c| {
        if (!std.ascii.isDigit(c)) {
            return fail(alloc, err_msg, "{s}: invalid number \"{s}\"", .{ label, s });
        }
    }
    return std.fmt.parseInt(i64, t, 10) catch
        fail(alloc, err_msg, "{s}: invalid number \"{s}\"", .{ label, s });
}

/// 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.
fn normalize(alloc: Allocator, value: []const u8, meta: FieldMeta) Error![]u8 {
    var v = try upperCopy(alloc, std.mem.trim(u8, value, " \t\r\n"));
    if (!meta.named) return v;
    const tokens: []const TokenValue = if (meta.name == .month) &month_tokens else &dow_tokens;
    for (tokens) |tv| {
        var buf: [4]u8 = undefined;
        const numstr = std.fmt.bufPrint(&buf, "{d}", .{tv.value}) catch unreachable;
        v = try replaceAll(alloc, v, tv.token, numstr);
    }
    return v;
}

/// 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 `*`.
pub fn expandField(alloc: Allocator, value: []const u8, meta: FieldMeta, err_msg: *?[]u8) Error!ParsedField {
    const label = meta.name.label();
    const norm = try normalize(alloc, value, meta);
    if (norm.len == 0) return fail(alloc, err_msg, "{s}: empty field", .{label});
    if (std.mem.eql(u8, norm, "*")) {
        const vals = alloc.alloc(i64, @intCast(meta.max - meta.min + 1)) catch return error.OutOfMemory;
        var i: i64 = meta.min;
        while (i <= meta.max) : (i += 1) {
            vals[@intCast(i - meta.min)] = i;
        }
        return .{ .meta = meta, .raw = value, .values = vals, .wildcard = true };
    }

    // Minute is the widest field (0..59), so 60 slots bound every expansion.
    var set: [60]i64 = undefined;
    var count: usize = 0;

    var terms = std.mem.splitScalar(u8, norm, ',');
    while (terms.next()) |term| {
        if (term.len == 0) return fail(alloc, err_msg, "{s}: empty list item", .{label});

        var base: []const u8 = term;
        var step: i64 = 1;
        var has_slash = false;
        if (std.mem.indexOfScalar(u8, term, '/')) |slash| {
            has_slash = true;
            base = term[0..slash];
            step = try parseIntStrict(alloc, term[slash + 1 ..], label, err_msg);
            if (step <= 0) {
                return fail(alloc, err_msg, "{s}: step must be a positive number", .{label});
            }
        }

        var lo: i64 = undefined;
        var hi: i64 = undefined;
        if (std.mem.eql(u8, base, "*")) {
            lo = meta.min;
            hi = meta.max;
        } else if (std.mem.indexOfScalar(u8, base, '-')) |dash| {
            lo = try parseIntStrict(alloc, base[0..dash], label, err_msg);
            hi = try parseIntStrict(alloc, base[dash + 1 ..], label, err_msg);
        } else {
            lo = try parseIntStrict(alloc, base, label, err_msg);
            // "A/step" runs from A to the field max; a bare "A" is a single value.
            hi = if (has_slash) meta.max else lo;
        }

        if (lo > hi) {
            return fail(alloc, err_msg, "{s}: range start {d} is greater than end {d}", .{ label, lo, hi });
        }
        if (lo < meta.min) {
            return fail(alloc, err_msg, "{s}: value {d} is below minimum {d}", .{ label, lo, meta.min });
        }
        if (hi > meta.max) {
            return fail(alloc, err_msg, "{s}: value {d} is above maximum {d}", .{ label, hi, meta.max });
        }

        var v: i64 = lo;
        while (v <= hi) : (v += step) {
            const resolved: i64 = if (meta.wrap_max and v == meta.max) meta.min else v;
            var dup = false;
            for (set[0..count]) |seen| {
                if (seen == resolved) {
                    dup = true;
                    break;
                }
            }
            if (!dup) {
                set[count] = resolved;
                count += 1;
            }
        }
    }

    const vals = alloc.alloc(i64, count) catch return error.OutOfMemory;
    @memcpy(vals, set[0..count]);
    std.sort.sort(i64, vals, {}, std.sort.asc(i64));
    return .{ .meta = meta, .raw = value, .values = vals, .wildcard = false };
}

/// Parse all five fields, or return `error.InvalidCron` with the
/// human-readable message in `err_msg`.
pub fn parseExpr(alloc: Allocator, expr: []const u8, err_msg: *?[]u8) Error![5]ParsedField {
    var tokens: [5][]const u8 = undefined;
    var count: usize = 0;
    var it = std.mem.tokenizeAny(u8, expr, " \t\r\n");
    while (it.next()) |tok| {
        if (count < 5) tokens[count] = tok;
        count += 1;
    }
    if (count != 5) {
        return fail(alloc, err_msg, "Expected 5 fields (minute hour day-of-month month day-of-week), got {d}", .{count});
    }
    var parts: [5]ParsedField = undefined;
    for (0..5) |i| {
        parts[i] = try expandField(alloc, tokens[i], fields[i], err_msg);
    }
    return parts;
}

/// True when a sorted value list is a contiguous run (e.g. [3, 4, 5, 6]).
fn isContiguous(values: []const i64) bool {
    var i: usize = 1;
    while (i < values.len) : (i += 1) {
        if (values[i] - values[i - 1] != 1) return false;
    }
    return true;
}

/// Describe a single value in the field's own vocabulary.
fn singleValue(alloc: Allocator, n: i64, meta: FieldMeta) Error![]u8 {
    return switch (meta.name) {
        .minute => std.fmt.allocPrint(alloc, "minute {d}", .{n}) catch return error.OutOfMemory,
        .hour => std.fmt.allocPrint(alloc, "hour {d}", .{n}) catch return error.OutOfMemory,
        .day_of_month => std.fmt.allocPrint(alloc, "day {d} of the month", .{n}) catch return error.OutOfMemory,
        .month => monthName(alloc, n),
        .day_of_week => dowName(alloc, n),
    };
}

/// Concatenate two slices into a newly allocated one.
fn concat(alloc: Allocator, a: []const u8, b: []const u8) Error![]u8 {
    const out = alloc.alloc(u8, a.len + b.len) catch return error.OutOfMemory;
    @memcpy(out[0..a.len], a);
    @memcpy(out[a.len..], b);
    return out;
}

/// Join slices with a separator into one newly allocated slice.
fn join(alloc: Allocator, parts_: []const []const u8, sep: []const u8) Error![]u8 {
    var out: []u8 = alloc.alloc(u8, 0) catch return error.OutOfMemory;
    for (parts_, 0..) |p, i| {
        if (i > 0) out = try concat(alloc, out, sep);
        out = try concat(alloc, out, p);
    }
    return out;
}

/// Join integers with a separator into one newly allocated slice.
fn joinInts(alloc: Allocator, values: []const i64, sep: []const u8) Error![]u8 {
    var out: []u8 = alloc.alloc(u8, 0) catch return error.OutOfMemory;
    for (values, 0..) |v, i| {
        if (i > 0) out = try concat(alloc, out, sep);
        const s = std.fmt.allocPrint(alloc, "{d}", .{v}) catch return error.OutOfMemory;
        out = try concat(alloc, out, s);
    }
    return out;
}

/// Plural unit noun for step and range phrasing ("every 5 minutes",
/// "minutes 3 through 6").
fn unitPlural(meta: FieldMeta, of_the: bool) []const u8 {
    return switch (meta.name) {
        .minute => "minutes",
        .hour => "hours",
        .month => "months",
        .day_of_month => if (of_the) "days of the month" else "days",
        .day_of_week => "days of the week",
    };
}

/// 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.
fn describeField(alloc: Allocator, p: ParsedField) Error![]u8 {
    const meta = p.meta;
    const values = p.values;
    const label = meta.name.label();

    if (p.wildcard) {
        return switch (meta.name) {
            .minute => alloc.dupe(u8, "every minute") catch return error.OutOfMemory,
            .hour => alloc.dupe(u8, "every hour") catch return error.OutOfMemory,
            .day_of_month => alloc.dupe(u8, "every day of the month") catch return error.OutOfMemory,
            .month => alloc.dupe(u8, "every month") catch return error.OutOfMemory,
            .day_of_week => alloc.dupe(u8, "every day of the week") catch return error.OutOfMemory,
        };
    }

    // Step syntax is reported as "every N <units>".
    if (std.mem.indexOfScalar(u8, p.raw, '/')) |slash| {
        if (values.len > 0) {
            var step: i64 = 1;
            var probe: ?[]u8 = null;
            if (parseIntStrict(alloc, p.raw[slash + 1 ..], label, &probe)) |s| {
                step = s;
            } else |_| {
                step = 1; // multi-term raw ("*/5,10-20/3") — fall back like the Rust port
            }
            const start = values[0];
            const head = std.fmt.allocPrint(alloc, "every {d} {s}", .{ step, unitPlural(meta, true) }) catch return error.OutOfMemory;
            if (start == meta.min) return head;
            const sv = try singleValue(alloc, start, meta);
            const withHead = try concat(alloc, head, " starting at ");
            return concat(alloc, withHead, sv);
        }
    }

    if (values.len == 1) return singleValue(alloc, values[0], meta);

    if (isContiguous(values)) {
        const a = values[0];
        const b = values[values.len - 1];
        if (meta.name == .month or meta.name == .day_of_week) {
            const an = if (meta.name == .month) try monthName(alloc, a) else try dowName(alloc, a);
            const bn = if (meta.name == .month) try monthName(alloc, b) else try dowName(alloc, b);
            const mid = try concat(alloc, an, " through ");
            return concat(alloc, mid, bn);
        }
        return std.fmt.allocPrint(alloc, "{s} {d} through {d}", .{ unitPlural(meta, false), a, b }) catch return error.OutOfMemory;
    }

    // Explicit list of discrete values.
    if (meta.name == .month or meta.name == .day_of_week) {
        const names = alloc.alloc([]const u8, values.len) catch return error.OutOfMemory;
        for (values, 0..) |v, i| {
            names[i] = if (meta.name == .month) try monthName(alloc, v) else try dowName(alloc, v);
        }
        return join(alloc, names, ", ");
    }
    const joined = try joinInts(alloc, values, ", ");
    switch (meta.name) {
        .minute => return concat(alloc, "minutes ", joined),
        .hour => return concat(alloc, "hours ", joined),
        .day_of_month => {
            const head = try concat(alloc, "days ", joined);
            return concat(alloc, head, " of the month");
        },
        else => unreachable,
    }
}

/// 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 ...").
fn prepend(alloc: Allocator, prefix: []const u8, phrase: []const u8) Error![]u8 {
    if (std.mem.startsWith(u8, phrase, "every")) {
        return alloc.dupe(u8, phrase) catch return error.OutOfMemory;
    }
    const head = try concat(alloc, prefix, " ");
    return concat(alloc, head, phrase);
}

/// Compose the opening time-of-day clause from the minute and hour fields.
fn timeClause(alloc: Allocator, minute: ParsedField, hour: ParsedField) Error![]u8 {
    const m_all = minute.wildcard;
    const h_all = hour.wildcard;
    const m_single = !m_all and minute.values.len == 1;
    const h_single = !h_all and hour.values.len == 1;

    if (m_all and h_all) {
        return alloc.dupe(u8, "Every minute") catch return error.OutOfMemory;
    }
    if (m_all and h_single) {
        return std.fmt.allocPrint(alloc, "Every minute of hour {d}", .{hour.values[0]}) catch return error.OutOfMemory;
    }
    if (m_single and h_all) {
        return std.fmt.allocPrint(alloc, "At minute {d} of every hour", .{minute.values[0]}) catch return error.OutOfMemory;
    }
    if (m_single and h_single) {
        const h = try pad2(alloc, hour.values[0]);
        const m = try pad2(alloc, minute.values[0]);
        const withH = try concat(alloc, "At ", h);
        const colon = try concat(alloc, withH, ":");
        return concat(alloc, colon, m);
    }

    // Mixed: describe each non-wildcard field, hour first.
    var clauses: [2][]u8 = undefined;
    var n: usize = 0;
    if (!h_all) {
        clauses[n] = try describeField(alloc, hour);
        n += 1;
    }
    if (!m_all) {
        clauses[n] = try describeField(alloc, minute);
        n += 1;
    }
    const s = try join(alloc, clauses[0..n], ", ");
    if (s.len > 0) s[0] = std.ascii.toUpper(s[0]);
    return s;
}

fn composeDescription(alloc: Allocator, parts: [5]ParsedField) Error![]u8 {
    var clauses: [4][]u8 = undefined;
    var n: usize = 0;
    clauses[n] = try timeClause(alloc, parts[0], parts[1]);
    n += 1;
    if (!parts[2].wildcard) {
        clauses[n] = try prepend(alloc, "on", try describeField(alloc, parts[2]));
        n += 1;
    }
    if (!parts[3].wildcard) {
        clauses[n] = try prepend(alloc, "in", try describeField(alloc, parts[3]));
        n += 1;
    }
    if (!parts[4].wildcard) {
        clauses[n] = try prepend(alloc, "on", try describeField(alloc, parts[4]));
        n += 1;
    }
    return join(alloc, clauses[0..n], ", ");
}

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

/// One entry of the per-field explanation.
pub const CronFieldInfo = struct {
    field: []u8,   // one of the five positional field names
    value: []u8,   // raw field value as written in the expression
    meaning: []u8, // human-readable description of what this field matches
};

/// The result of `explainCron`.
pub const CronExplanation = struct {
    valid: bool,
    description: []u8 = &.{},       // "" when invalid
    fields: []CronFieldInfo = &.{}, // one per field; empty when invalid
    err: ?[]u8 = null,              // present only when valid is false
};

/// Parse and explain a 5-field cron expression in plain English.
///
///     const e = try explainCron(a, "30 14 * * *");
///     try std.testing.expectEqualStrings("At 14:30", e.description);
pub fn explainCron(alloc: Allocator, expr: []const u8) Error!CronExplanation {
    var err_msg: ?[]u8 = null;
    const parts = parseExpr(alloc, expr, &err_msg) catch |e| switch (e) {
        error.InvalidCron => return .{ .valid = false, .err = err_msg },
        error.OutOfMemory => return error.OutOfMemory,
    };

    const infos = alloc.alloc(CronFieldInfo, 5) catch return error.OutOfMemory;
    for (parts, 0..) |p, i| {
        infos[i] = .{
            .field = alloc.dupe(u8, p.meta.name.label()) catch return error.OutOfMemory,
            .value = alloc.dupe(u8, p.raw) catch return error.OutOfMemory,
            .meaning = describeField(alloc, p) catch return error.OutOfMemory,
        };
    }
    return .{
        .valid = true,
        .description = try composeDescription(alloc, parts),
        .fields = infos,
    };
}

/// Per-field specs for `buildCron`. Null/empty fields default to `*`.
pub const BuildCronOptions = struct {
    minute: ?[]const u8 = null,
    hour: ?[]const u8 = null,
    dom: ?[]const u8 = null,
    month: ?[]const u8 = null,
    dow: ?[]const u8 = null,
};

/// Assemble a 5-field cron expression from per-field specs. Each field
/// defaults to `*` when empty/omitted; invalid fields return
/// `error.InvalidCron` (message in `err_msg`) so callers cannot build a
/// malformed expression.
///
///     const out = try buildCron(a, .{ .minute = "30", .hour = "14" }, &err);
///     try std.testing.expectEqualStrings("30 14 * * *", out);
pub fn buildCron(alloc: Allocator, opts: BuildCronOptions, err_msg: *?[]u8) Error![]u8 {
    const specs = [5]struct { meta: FieldMeta, value: ?[]const u8 }{
        .{ .meta = fields[0], .value = opts.minute },
        .{ .meta = fields[1], .value = opts.hour },
        .{ .meta = fields[2], .value = opts.dom },
        .{ .meta = fields[3], .value = opts.month },
        .{ .meta = fields[4], .value = opts.dow },
    };
    var out: [5][]const u8 = undefined;
    for (specs, 0..) |spec, i| {
        const raw = spec.value orelse "";
        const v = std.mem.trim(u8, raw, " \t\r\n");
        if (v.len == 0) {
            out[i] = "*";
            continue;
        }
        _ = try expandField(alloc, v, spec.meta, err_msg); // validates
        out[i] = v;
    }
    return join(alloc, &out, " ");
}

// ─── Civil-calendar helpers (Howard Hinnant, public domain) ─────────────────
//
// Convert between a proleptic Gregorian (year, month, day) and a serial day
// count anchored at 1970-01-01 = day 0. The formulas force non-negative
// operands before truncating division, so they are correct for all inputs and
// match Zig's (truncating) integer division.

fn daysFromCivil(y_in: i64, m: i64, d: i64) i64 {
    const y = if (m <= 2) y_in - 1 else y_in;
    const era = @divTrunc(if (y >= 0) y else y - 399, 400);
    const yoe = y - era * 400; // [0, 399]
    const doy = @divTrunc(153 * (if (m > 2) m - 3 else m + 9) + 2, 5) + d - 1; // [0, 365]
    const doe = yoe * 365 + @divTrunc(yoe, 4) - @divTrunc(yoe, 100) + doy; // [0, 146096]
    return era * 146097 + doe - 719468;
}

const Civil = struct { y: i64, m: i64, d: i64 };

fn civilFromDays(z_in: i64) Civil {
    const z = z_in + 719468;
    const era = @divTrunc(if (z >= 0) z else z - 146096, 146097);
    const doe = z - era * 146097; // [0, 146096]
    const yoe = @divTrunc(doe - @divTrunc(doe, 1460) + @divTrunc(doe, 36524) - @divTrunc(doe, 146096), 365); // [0, 399]
    const y = yoe + era * 400;
    const doy = doe - (365 * yoe + @divTrunc(yoe, 4) - @divTrunc(yoe, 100)); // [0, 365]
    const mp = @divTrunc(5 * doy + 2, 153); // [0, 11]
    const d = doy - @divTrunc(153 * mp + 2, 5) + 1; // [1, 31]
    const m = if (mp < 10) mp + 3 else mp - 9; // [1, 12]
    return .{ .y = if (m <= 2) y + 1 else y, .m = m, .d = d };
}

/// Weekday (0 = Sunday .. 6 = Saturday) for a serial day count.
/// 1970-01-01 was a Thursday (4), which anchors the +11 offset.
fn weekdayFromDays(z: i64) i64 {
    return @mod(@mod(z, 7) + 11, 7);
}

/// A UTC date/time expressed as civil fields — the same values the
/// TypeScript reference reads off a Date via its getUTC* accessors.
pub const CivilTime = struct {
    year: i64,
    month: i64,  // 1..=12
    day: i64,    // 1..=31
    hour: i64,   // 0..=23
    minute: i64, // 0..=59
};

/// Internal mutable cursor: a serial day count plus minutes-since-midnight.
/// Advancing a field is ordinary integer arithmetic, and field overflow rolls
/// over exactly like Date#setUTC* (e.g. minute 60 → next hour, day 32 →
/// next month, Feb 30 → March).
const UtcCursor = struct {
    days: i64,
    min_of_day: i64, // 0..=1439

    fn fromCivil(c: CivilTime) UtcCursor {
        return .{
            .days = daysFromCivil(c.year, c.month, c.day),
            .min_of_day = c.hour * 60 + c.minute,
        };
    }

    fn year(self: UtcCursor) i64 {
        return civilFromDays(self.days).y;
    }
    fn month(self: UtcCursor) i64 {
        return civilFromDays(self.days).m;
    }
    fn day(self: UtcCursor) i64 {
        return civilFromDays(self.days).d;
    }
    fn weekday(self: UtcCursor) i64 {
        return weekdayFromDays(self.days);
    }
    fn hour(self: UtcCursor) i64 {
        return @divTrunc(self.min_of_day, 60);
    }
    fn minute(self: UtcCursor) i64 {
        return @mod(self.min_of_day, 60);
    }

    /// +1 minute, seconds conceptually zero (sub-minute is never tracked).
    fn bumpMinute(self: *UtcCursor) void {
        self.min_of_day += 1;
        if (self.min_of_day >= 1440) {
            self.min_of_day -= 1440;
            self.days += 1;
        }
    }

    /// setUTCMonth(+1, 1) + zero time → first day of next month, midnight.
    fn advanceMonthDay1(self: *UtcCursor) void {
        const c = civilFromDays(self.days);
        const ny = if (c.m == 12) c.y + 1 else c.y;
        const nm = if (c.m == 12) 1 else c.m + 1;
        self.days = daysFromCivil(ny, nm, 1);
        self.min_of_day = 0;
    }

    /// setUTCDate(+1) + zero time → next day, midnight.
    fn advanceDay(self: *UtcCursor) void {
        self.days += 1;
        self.min_of_day = 0;
    }

    /// setUTCHours(+1, 0, 0, 0) → next hour with minute zeroed (may roll day).
    fn advanceHourZero(self: *UtcCursor) void {
        const new_hour = @divTrunc(self.min_of_day, 60) + 1;
        self.days += @divTrunc(new_hour, 24);
        self.min_of_day = @mod(new_hour, 24) * 60;
    }

    fn toCivil(self: UtcCursor) CivilTime {
        const c = civilFromDays(self.days);
        return .{ .year = c.y, .month = c.m, .day = c.d, .hour = self.hour(), .minute = self.minute() };
    }
};

fn inSet(values: []const i64, x: i64) bool {
    for (values) |v| {
        if (v == x) return true;
    }
    return false;
}

/// 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 null if no firing occurs within ~3 years.
pub fn nextRun(alloc: Allocator, expr: []const u8, after: CivilTime) Error!?CivilTime {
    var err_msg: ?[]u8 = null;
    const parts = parseExpr(alloc, expr, &err_msg) catch |e| switch (e) {
        error.InvalidCron => return null,
        error.OutOfMemory => return error.OutOfMemory,
    };
    const minute = parts[0];
    const hour = parts[1];
    const dom = parts[2];
    const month = parts[3];
    const dow = parts[4];
    const dom_wild = dom.wildcard;
    const dow_wild = dow.wildcard;

    // Start at the top of the minute following `after`, seconds zeroed.
    var cur = UtcCursor.fromCivil(after);
    cur.bumpMinute();

    const limit = cur.year() + 3; // hard stop ~3 years out
    while (cur.year() < limit) {
        if (!inSet(month.values, cur.month())) {
            cur.advanceMonthDay1();
            continue;
        }
        const dom_ok = inSet(dom.values, cur.day());
        const dow_ok = inSet(dow.values, cur.weekday()); // 0 = Sunday .. 6 = Saturday
        const day_ok = if (dom_wild or dow_wild) (dom_ok and dow_ok) else (dom_ok or dow_ok);
        if (!day_ok) {
            cur.advanceDay();
            continue;
        }
        if (!inSet(hour.values, cur.hour())) {
            cur.advanceHourZero();
            continue;
        }
        if (!inSet(minute.values, cur.minute())) {
            cur.bumpMinute();
            continue;
        }
        return cur.toCivil();
    }
    return null;
}

test "explains simple time" {
    var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena.deinit();
    const e = try explainCron(arena.allocator(), "30 14 * * *");
    try std.testing.expect(e.valid);
    try std.testing.expectEqualStrings("At 14:30", e.description);
}

test "next run lands on the next matching minute" {
    var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena.deinit();
    const got = (try nextRun(arena.allocator(), "30 14 * * *", .{
        .year = 2026,
        .month = 9,
        .day = 4,
        .hour = 14,
        .minute = 30,
    })) orelse return error.TestUnexpectedResult;
    // 14:30 already passed on 2026-09-04 → the next firing is tomorrow.
    try std.testing.expectEqual(@as(i64, 2026), got.year);
    try std.testing.expectEqual(@as(i64, 9), got.month);
    try std.testing.expectEqual(@as(i64, 5), got.day);
    try std.testing.expectEqual(@as(i64, 14), got.hour);
    try std.testing.expectEqual(@as(i64, 30), got.minute);
}

test "rejects expressions without five fields" {
    var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena.deinit();
    const e = try explainCron(arena.allocator(), "30 14 * *");
    try std.testing.expect(!e.valid);
    try std.testing.expect(e.err != 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 →