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 →