Cron Expression Explainer — C 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 C 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 — C polyglot showcase port.
*
* Language: C (C11, 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).
*
* Memory model: every function that produces a string returns heap memory.
* explain_cron() bundles its strings into a CronExplanation that the caller
* releases with free_cron_explanation(); build_cron() returns a string the
* caller frees with free(). Allocation failure aborts (xrealloc) — the tiny
* fixed-size workload makes recovery plumbing noise.
*
* Date handling: C11's <time.h> offers only local-time mktime, so — like the
* Rust sibling port — this showcase works against a small CivilTime value
* object plus Howard Hinnant's proleptic-Gregorian serial-day algorithms
* (days_from_civil / civil_from_days). All arithmetic is plain long long;
* field overflow rolls over exactly like the JS reference's setUTC* family.
*
* The public surface mirrors the TypeScript reference: explain_cron,
* build_cron, next_run.
*/
#include <ctype.h>
#include <stdarg.h>
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* ─── Allocation helpers ──────────────────────────────────────────────────── */
static void *xrealloc(void *p, size_t n) {
void *q = realloc(p, n);
if (q == NULL) {
fprintf(stderr, "cron-explainer: out of memory\n");
abort();
}
return q;
}
static char *xstrdup(const char *s) {
size_t n = strlen(s) + 1;
char *out = xrealloc(NULL, n);
memcpy(out, s, n);
return out;
}
/* Growable, always NUL-terminated string builder. */
typedef struct {
char *data;
size_t len;
size_t cap;
} StrBuf;
static void sb_init(StrBuf *sb) {
sb->cap = 16;
sb->len = 0;
sb->data = xrealloc(NULL, sb->cap);
sb->data[0] = '\0';
}
static void sb_append_n(StrBuf *sb, const char *s, size_t n) {
if (sb->len + n + 1 > sb->cap) {
while (sb->len + n + 1 > sb->cap) sb->cap *= 2;
sb->data = xrealloc(sb->data, sb->cap);
}
memcpy(sb->data + sb->len, s, n);
sb->len += n;
sb->data[sb->len] = '\0';
}
static void sb_append(StrBuf *sb, const char *s) {
sb_append_n(sb, s, strlen(s));
}
/* printf-style append. */
static void sb_appendf(StrBuf *sb, const char *fmt, ...) {
va_list ap;
va_start(ap, fmt);
int need = vsnprintf(NULL, 0, fmt, ap);
va_end(ap);
if (need < 0) return;
char buf[64];
char *dst = buf;
if ((size_t)need + 1 > sizeof buf) {
dst = xrealloc(NULL, (size_t)need + 1);
}
va_start(ap, fmt);
vsnprintf(dst, (size_t)need + 1, fmt, ap);
va_end(ap);
sb_append_n(sb, dst, (size_t)need);
if (dst != buf) free(dst);
}
/* Detach the built string (caller owns it). */
static char *sb_take(StrBuf *sb) {
return sb->data;
}
/* ─── 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). */
typedef enum { FLD_MINUTE, FLD_HOUR, FLD_DOM, FLD_MONTH, FLD_DOW } FieldId;
typedef struct {
FieldId id;
const char *name; /* label used in error messages and pluralised phrases */
int min;
int max;
bool named;
bool wrap_max;
} FieldMeta;
static const FieldMeta FIELDS[5] = {
{FLD_MINUTE, "minute", 0, 59, false, false},
{FLD_HOUR, "hour", 0, 23, false, false},
{FLD_DOM, "day-of-month", 1, 31, false, false},
{FLD_MONTH, "month", 1, 12, true, false},
{FLD_DOW, "day-of-week", 0, 7, true, true},
};
static const char *const MONTH_NAMES[12] = {
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
};
static const char *const DOW_NAMES[7] = {
"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. */
typedef struct {
const char *token;
int value;
} TokenVal;
static const TokenVal MONTH_TOKENS[12] = {
{"JAN", 1}, {"FEB", 2}, {"MAR", 3}, {"APR", 4}, {"MAY", 5}, {"JUN", 6},
{"JUL", 7}, {"AUG", 8}, {"SEP", 9}, {"OCT", 10}, {"NOV", 11}, {"DEC", 12},
};
static const TokenVal DOW_TOKENS[7] = {
{"SUN", 0}, {"MON", 1}, {"TUE", 2}, {"WED", 3}, {"THU", 4}, {"FRI", 5}, {"SAT", 6},
};
/* ─── Error plumbing ─────────────────────────────────────────────────────────
*
* Internal functions thread a char **err slot: it starts NULL, and the first
* failure writes a malloc'd message into it (later writes are ignored). A
* non-NULL *err short-circuits every caller. */
static void err_set(char **err, const char *fmt, ...) {
if (*err != NULL) return; /* first failure wins, like an exception */
va_list ap;
va_start(ap, fmt);
int need = vsnprintf(NULL, 0, fmt, ap);
va_end(ap);
if (need < 0) {
*err = xstrdup("invalid field");
return;
}
char *msg = xrealloc(NULL, (size_t)need + 1);
va_start(ap, fmt);
vsnprintf(msg, (size_t)need + 1, fmt, ap);
va_end(ap);
*err = msg;
}
static char *trim_dup(const char *s) {
while (*s == ' ' || *s == '\t' || *s == '\r' || *s == '\n') s++;
size_t n = strlen(s);
while (n > 0 && (s[n - 1] == ' ' || s[n - 1] == '\t' || s[n - 1] == '\r' || s[n - 1] == '\n')) {
n--;
}
char *out = xrealloc(NULL, n + 1);
memcpy(out, s, n);
out[n] = '\0';
return out;
}
static char *pad2(int n) {
char buf[8];
snprintf(buf, sizeof buf, "%02d", n);
return xstrdup(buf);
}
static const char *month_name(int m) {
return MONTH_NAMES[m - 1];
}
static const char *dow_name(int d) {
return DOW_NAMES[((d % 7) + 7) % 7];
}
/* Parse a strictly-numeric token (ASCII digits only). Rejects named tokens,
* signs, and surrounding garbage so malformed fields surface clearly. */
static long long parse_int_strict(const char *s, const char *label, char **err) {
char *t = trim_dup(s);
size_t n = strlen(t);
bool digits = n > 0;
for (size_t i = 0; i < n; i++) {
if (t[i] < '0' || t[i] > '9') {
digits = false;
break;
}
}
if (!digits) {
err_set(err, "%s: invalid number \"%s\"", label, s);
free(t);
return 0;
}
long long v = strtoll(t, NULL, 10);
free(t);
return v;
}
/* Replace every occurrence of `needle` in `hay` with `repl` (new string). */
static char *replace_all(const char *hay, const char *needle, const char *repl) {
StrBuf sb;
sb_init(&sb);
size_t nl = strlen(needle);
const char *p = hay;
for (;;) {
const char *hit = strstr(p, needle);
if (hit == NULL) {
sb_append(&sb, p);
break;
}
sb_append_n(&sb, p, (size_t)(hit - p));
sb_append(&sb, repl);
p = hit + nl;
}
return sb_take(&sb);
}
/* 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. */
static char *normalize(const char *value, const FieldMeta *meta) {
char *v = trim_dup(value);
for (char *p = v; *p; p++) {
*p = (char)toupper((unsigned char)*p);
}
if (!meta->named) return v;
const TokenVal *tokens = meta->id == FLD_MONTH ? MONTH_TOKENS : DOW_TOKENS;
size_t ntok = meta->id == FLD_MONTH ? 12 : 7;
for (size_t i = 0; i < ntok; i++) {
char num[8];
snprintf(num, sizeof num, "%d", tokens[i].value);
char *next = replace_all(v, tokens[i].token, num);
free(v);
v = next;
}
return v;
}
/* A field after expansion: the matched values plus the raw token and a flag
* distinguishing a bare '*' (wildcard) from an explicit enumeration. */
typedef struct {
const FieldMeta *meta;
char *raw; /* owned */
int values[60]; /* minute is the widest field: 0..59 */
size_t count;
bool wildcard;
} ParsedField;
static void parsed_field_free(ParsedField *p) {
free(p->raw);
p->raw = NULL;
}
static int cmp_int(const void *a, const void *b) {
int x = *(const int *)a, y = *(const int *)b;
return (x > y) - (x < y);
}
/* 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. Produces the deduped, sorted
* values plus a wildcard flag for a bare '*'. */
static void expand_field(const char *value, const FieldMeta *meta, ParsedField *out, char **err) {
const char *label = meta->name;
out->meta = meta;
out->raw = xstrdup(value);
out->count = 0;
out->wildcard = false;
char *norm = normalize(value, meta);
if (*norm == '\0') {
err_set(err, "%s: empty field", label);
free(norm);
return;
}
if (strcmp(norm, "*") == 0) {
for (int v = meta->min; v <= meta->max; v++) {
out->values[out->count++] = v;
}
out->wildcard = true;
free(norm);
return;
}
const char *cursor = norm;
for (;;) {
const char *comma = strchr(cursor, ',');
size_t tlen = comma != NULL ? (size_t)(comma - cursor) : strlen(cursor);
char *term = xrealloc(NULL, tlen + 1);
memcpy(term, cursor, tlen);
term[tlen] = '\0';
if (*term == '\0') {
err_set(err, "%s: empty list item", label);
free(term);
break;
}
char *base = term;
long long step = 1;
bool has_slash = false;
char *slash = strchr(term, '/');
if (slash != NULL) {
*slash = '\0';
base = term;
step = parse_int_strict(slash + 1, label, err);
if (*err == NULL && step <= 0) {
err_set(err, "%s: step must be a positive number", label);
}
has_slash = true;
}
if (*err == NULL) {
long long lo, hi;
if (strcmp(base, "*") == 0) {
lo = meta->min;
hi = meta->max;
} else {
char *dash = strchr(base, '-');
if (dash != NULL) {
*dash = '\0';
lo = parse_int_strict(base, label, err);
if (*err == NULL) hi = parse_int_strict(dash + 1, label, err);
} else {
lo = parse_int_strict(base, label, err);
/* "A/step" runs from A to the field max; a bare "A" is a
* single value. */
hi = has_slash ? meta->max : lo;
}
}
if (*err == NULL) {
if (lo > hi) {
err_set(err, "%s: range start %lld is greater than end %lld", label, lo, hi);
} else if (lo < meta->min) {
err_set(err, "%s: value %lld is below minimum %d", label, lo, meta->min);
} else if (hi > meta->max) {
err_set(err, "%s: value %lld is above maximum %d", label, hi, meta->max);
}
}
if (*err == NULL) {
for (long long v = lo; v <= hi; v += step) {
int resolved = (meta->wrap_max && v == meta->max) ? meta->min : (int)v;
bool dup = false;
for (size_t i = 0; i < out->count; i++) {
if (out->values[i] == resolved) {
dup = true;
break;
}
}
if (!dup) out->values[out->count++] = resolved;
}
}
}
free(term);
if (comma == NULL || *err != NULL) break;
cursor = comma + 1;
}
if (*err == NULL) {
qsort(out->values, out->count, sizeof(int), cmp_int);
}
free(norm);
}
/* Parse all five fields into `parts`. On failure *err holds the message and
* the fields populated so far are released. */
static void parse_expr(const char *expr, ParsedField parts[5], char **err) {
/* Pure-C11 whitespace tokenizer (strtok_r is POSIX, not ISO). */
char *copy = xstrdup(expr);
size_t ntok = 0;
char *tokens[8]; /* 5 expected; extra slots cap the "got N" count */
char *p = copy;
while (*p != '\0') {
while (*p == ' ' || *p == '\t' || *p == '\r' || *p == '\n') p++;
if (*p == '\0') break;
char *start = p;
while (*p != '\0' && *p != ' ' && *p != '\t' && *p != '\r' && *p != '\n') p++;
if (*p != '\0') {
*p = '\0';
p++;
}
if (ntok < 8) tokens[ntok] = start;
ntok++;
}
if (ntok != 5) {
err_set(err, "Expected 5 fields (minute hour day-of-month month day-of-week), got %zu", ntok);
free(copy);
return;
}
for (size_t i = 0; i < 5; i++) {
expand_field(tokens[i], &FIELDS[i], &parts[i], err);
if (*err != NULL) {
/* expand_field always sets raw before failing, so free through i. */
for (size_t j = 0; j <= i; j++) parsed_field_free(&parts[j]);
free(copy);
return;
}
}
free(copy);
}
static bool is_contiguous(const int *values, size_t n) {
for (size_t i = 1; i < n; i++) {
if (values[i] - values[i - 1] != 1) return false;
}
return true;
}
/* Describe a single value in the field's own vocabulary. */
static char *single_value(int n, const FieldMeta *meta) {
StrBuf sb;
sb_init(&sb);
switch (meta->id) {
case FLD_MINUTE: sb_appendf(&sb, "minute %d", n); break;
case FLD_HOUR: sb_appendf(&sb, "hour %d", n); break;
case FLD_DOM: sb_appendf(&sb, "day %d of the month", n); break;
case FLD_MONTH: sb_append(&sb, month_name(n)); break;
case FLD_DOW: sb_append(&sb, dow_name(n)); break;
}
return sb_take(&sb);
}
/* 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. */
static char *describe_field(const ParsedField *p) {
const FieldMeta *meta = p->meta;
const int *values = p->values;
const char *label = meta->name;
StrBuf sb;
sb_init(&sb);
if (p->wildcard) {
switch (meta->id) {
case FLD_MINUTE: sb_append(&sb, "every minute"); break;
case FLD_HOUR: sb_append(&sb, "every hour"); break;
case FLD_DOM: sb_append(&sb, "every day of the month"); break;
case FLD_MONTH: sb_append(&sb, "every month"); break;
case FLD_DOW: sb_append(&sb, "every day of the week"); break;
}
return sb_take(&sb);
}
/* Step syntax is reported as "every N <units>". */
const char *slash = strchr(p->raw, '/');
if (slash != NULL && p->count > 0) {
char *probe_err = NULL;
long long step = parse_int_strict(slash + 1, label, &probe_err);
if (probe_err != NULL) {
step = 1; /* multi-term raw ("* /5,10-20/3") — fall back like the Rust port */
free(probe_err);
}
int start = values[0];
const char *unit_plural;
switch (meta->id) {
case FLD_DOM: unit_plural = "days of the month"; break;
case FLD_DOW: unit_plural = "days of the week"; break;
default: unit_plural = meta->id == FLD_MINUTE ? "minutes"
: (meta->id == FLD_HOUR ? "hours" : "months"); break;
}
if (start == meta->min) {
sb_appendf(&sb, "every %lld %s", step, unit_plural);
return sb_take(&sb);
}
char *sv = single_value(start, meta);
sb_appendf(&sb, "every %lld %s starting at %s", step, unit_plural, sv);
free(sv);
return sb_take(&sb);
}
if (p->count == 1) {
char *sv = single_value(values[0], meta);
sb_append(&sb, sv);
free(sv);
return sb_take(&sb);
}
if (is_contiguous(values, p->count)) {
int a = values[0], b = values[p->count - 1];
if (meta->id == FLD_MONTH) {
sb_appendf(&sb, "%s through %s", month_name(a), month_name(b));
} else if (meta->id == FLD_DOW) {
sb_appendf(&sb, "%s through %s", dow_name(a), dow_name(b));
} else {
const char *unit_plural =
meta->id == FLD_DOM ? "days"
: (meta->id == FLD_MINUTE ? "minutes" : "hours");
sb_appendf(&sb, "%s %d through %d", unit_plural, a, b);
}
return sb_take(&sb);
}
/* Explicit list of discrete values. */
if (meta->id == FLD_MONTH || meta->id == FLD_DOW) {
for (size_t i = 0; i < p->count; i++) {
if (i > 0) sb_append(&sb, ", ");
sb_append(&sb, meta->id == FLD_MONTH ? month_name(values[i]) : dow_name(values[i]));
}
return sb_take(&sb);
}
const char *head = meta->id == FLD_MINUTE ? "minutes "
: (meta->id == FLD_HOUR ? "hours " : "days ");
sb_append(&sb, head);
for (size_t i = 0; i < p->count; i++) {
if (i > 0) sb_append(&sb, ", ");
sb_appendf(&sb, "%d", values[i]);
}
if (meta->id == FLD_DOM) sb_append(&sb, " of the month");
return sb_take(&sb);
}
/* 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 ..."). */
static char *prepend(const char *prefix, const char *phrase) {
if (strncmp(phrase, "every", 5) == 0) return xstrdup(phrase);
StrBuf sb;
sb_init(&sb);
sb_appendf(&sb, "%s %s", prefix, phrase);
return sb_take(&sb);
}
/* Compose the opening time-of-day clause from the minute and hour fields. */
static char *time_clause(const ParsedField *minute, const ParsedField *hour) {
bool m_all = minute->wildcard;
bool h_all = hour->wildcard;
bool m_single = !m_all && minute->count == 1;
bool h_single = !h_all && hour->count == 1;
StrBuf sb;
sb_init(&sb);
if (m_all && h_all) {
sb_append(&sb, "Every minute");
return sb_take(&sb);
}
if (m_all && h_single) {
sb_appendf(&sb, "Every minute of hour %d", hour->values[0]);
return sb_take(&sb);
}
if (m_single && h_all) {
sb_appendf(&sb, "At minute %d of every hour", minute->values[0]);
return sb_take(&sb);
}
if (m_single && h_single) {
char *h = pad2(hour->values[0]);
char *m = pad2(minute->values[0]);
sb_appendf(&sb, "At %s:%s", h, m);
free(h);
free(m);
return sb_take(&sb);
}
/* Mixed: describe each non-wildcard field, hour first. */
char *parts_buf[2];
size_t n = 0;
if (!h_all) parts_buf[n++] = describe_field(hour);
if (!m_all) parts_buf[n++] = describe_field(minute);
for (size_t i = 0; i < n; i++) {
if (i > 0) sb_append(&sb, ", ");
sb_append(&sb, parts_buf[i]);
free(parts_buf[i]);
}
char *out = sb_take(&sb);
if (*out != '\0') out[0] = (char)toupper((unsigned char)out[0]);
return out;
}
static char *compose_description(const ParsedField parts[5]) {
const ParsedField *minute = &parts[0], *hour = &parts[1], *dom = &parts[2],
*month = &parts[3], *dow = &parts[4];
StrBuf sb;
sb_init(&sb);
char *first = time_clause(minute, hour);
sb_append(&sb, first);
free(first);
if (!dom->wildcard) {
char *d = describe_field(dom);
char *p = prepend("on", d);
sb_appendf(&sb, ", %s", p);
free(d);
free(p);
}
if (!month->wildcard) {
char *d = describe_field(month);
char *p = prepend("in", d);
sb_appendf(&sb, ", %s", p);
free(d);
free(p);
}
if (!dow->wildcard) {
char *d = describe_field(dow);
char *p = prepend("on", d);
sb_appendf(&sb, ", %s", p);
free(d);
free(p);
}
return sb_take(&sb);
}
/* ─── Public API ──────────────────────────────────────────────────────────── */
/* One entry of the per-field explanation. */
typedef struct {
char *field; /* one of the five positional field names */
char *value; /* raw field value as written in the expression */
char *meaning; /* human-readable description of what this field matches */
} CronFieldInfo;
/* The result of explain_cron(). */
typedef struct {
bool valid;
char *description; /* "" when invalid */
CronFieldInfo fields[5];
size_t field_count; /* 0 when invalid */
char *error; /* NULL when valid */
} CronExplanation;
/* Release every string inside an explanation produced by explain_cron(). */
void free_cron_explanation(CronExplanation *e) {
free(e->description);
for (size_t i = 0; i < e->field_count; i++) {
free(e->fields[i].field);
free(e->fields[i].value);
free(e->fields[i].meaning);
}
free(e->error);
e->description = NULL;
e->field_count = 0;
e->error = NULL;
}
/*
* Parse and explain a 5-field cron expression in plain English.
*
* CronExplanation e = explain_cron("30 14 * * *");
* // e.description == "At 14:30"
* free_cron_explanation(&e);
*/
CronExplanation explain_cron(const char *expr) {
CronExplanation out = {false, NULL, {{0}}, 0, NULL};
char *err = NULL;
ParsedField parts[5];
parse_expr(expr, parts, &err);
if (err != NULL) {
out.description = xstrdup("");
out.error = err;
return out;
}
out.valid = true;
out.description = compose_description(parts);
for (size_t i = 0; i < 5; i++) {
char *meaning = describe_field(&parts[i]);
out.fields[i].field = xstrdup(parts[i].meta->name);
out.fields[i].value = xstrdup(parts[i].raw);
out.fields[i].meaning = meaning;
out.field_count++;
parsed_field_free(&parts[i]);
}
return out;
}
/* Per-field specs for build_cron(). NULL or empty fields default to '*'. */
typedef struct {
const char *minute;
const char *hour;
const char *dom;
const char *month;
const char *dow;
} BuildCronOptions;
/*
* Assemble a 5-field cron expression from per-field specs. Each field
* defaults to '*' when empty/omitted; invalid fields yield an error message
* in *err (caller frees) and a NULL return, so callers cannot build a
* malformed expression.
*
* BuildCronOptions opts = {.minute = "30", .hour = "14"};
* char *expr = build_cron(opts, NULL); // "30 14 * * *"
*/
char *build_cron(BuildCronOptions opts, char **err_out) {
char *err = NULL;
char **err_slot = err_out != NULL ? err_out : &err;
const char *values[5] = {opts.minute, opts.hour, opts.dom, opts.month, opts.dow};
char *out[5];
StrBuf sb;
sb_init(&sb);
for (size_t i = 0; i < 5; i++) {
char *v = values[i] != NULL ? trim_dup(values[i]) : xstrdup("");
if (*v == '\0') {
out[i] = xstrdup("*");
free(v);
continue;
}
ParsedField parsed;
expand_field(v, &FIELDS[i], &parsed, err_slot); /* validates */
parsed_field_free(&parsed);
if (*err_slot != NULL) {
for (size_t j = 0; j < i; j++) free(out[j]); /* out[i] not yet set */
free(v);
return NULL;
}
out[i] = v;
}
for (size_t i = 0; i < 5; i++) {
if (i > 0) sb_append(&sb, " ");
sb_append(&sb, out[i]);
free(out[i]);
}
return sb_take(&sb);
}
/* ─── 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 C's (toward-zero) integer division. */
static long long days_from_civil(long long y, long long m, long long d) {
if (m <= 2) y -= 1;
long long era = (y >= 0 ? y : y - 399) / 400;
long long yoe = y - era * 400; /* [0, 399] */
long long doy = (153 * (m > 2 ? m - 3 : m + 9) + 2) / 5 + d - 1; /* [0, 365] */
long long doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; /* [0, 146096] */
return era * 146097 + doe - 719468;
}
static void civil_from_days(long long z, long long *y, long long *m, long long *d) {
z += 719468;
long long era = (z >= 0 ? z : z - 146096) / 146097;
long long doe = z - era * 146097; /* [0, 146096] */
long long yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; /* [0, 399] */
long long yy = yoe + era * 400;
long long doy = doe - (365 * yoe + yoe / 4 - yoe / 100); /* [0, 365] */
long long mp = (5 * doy + 2) / 153; /* [0, 11] */
*d = doy - (153 * mp + 2) / 5 + 1; /* [1, 31] */
*m = mp < 10 ? mp + 3 : mp - 9; /* [1, 12] */
*y = *m <= 2 ? yy + 1 : yy;
}
/* Weekday (0 = Sunday .. 6 = Saturday) for a serial day count.
* 1970-01-01 was a Thursday (4), which anchors the +11 offset. */
static long long weekday_from_days(long long z) {
return ((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. */
typedef struct {
long long year;
long long month; /* 1..=12 */
long long day; /* 1..=31 */
long long hour; /* 0..=23 */
long long minute; /* 0..=59 */
} CivilTime;
/* 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). */
typedef struct {
long long days;
long long min_of_day; /* 0..=1439 */
} UtcCursor;
static UtcCursor cursor_from_civil(CivilTime c) {
UtcCursor cur = {days_from_civil(c.year, c.month, c.day), c.hour * 60 + c.minute};
return cur;
}
static long long cur_year(UtcCursor cur) {
long long y, m, d;
civil_from_days(cur.days, &y, &m, &d);
(void)m;
(void)d;
return y;
}
static long long cur_month(UtcCursor cur) {
long long y, m, d;
civil_from_days(cur.days, &y, &m, &d);
(void)y;
(void)d;
return m;
}
static long long cur_day(UtcCursor cur) {
long long y, m, d;
civil_from_days(cur.days, &y, &m, &d);
(void)y;
(void)m;
return d;
}
/* +1 minute, seconds conceptually zero (sub-minute is never tracked). */
static void cursor_bump_minute(UtcCursor *cur) {
cur->min_of_day += 1;
if (cur->min_of_day >= 1440) {
cur->min_of_day -= 1440;
cur->days += 1;
}
}
/* setUTCMonth(+1, 1) + zero time → first day of next month, midnight. */
static void cursor_advance_month_day1(UtcCursor *cur) {
long long y, m, d;
civil_from_days(cur->days, &y, &m, &d);
(void)d;
long long ny = m == 12 ? y + 1 : y;
long long nm = m == 12 ? 1 : m + 1;
cur->days = days_from_civil(ny, nm, 1);
cur->min_of_day = 0;
}
/* setUTCDate(+1) + zero time → next day, midnight. */
static void cursor_advance_day(UtcCursor *cur) {
cur->days += 1;
cur->min_of_day = 0;
}
/* setUTCHours(+1, 0, 0, 0) → next hour with minute zeroed (may roll day). */
static void cursor_advance_hour_zero(UtcCursor *cur) {
long long new_hour = cur->min_of_day / 60 + 1;
cur->days += new_hour / 24;
cur->min_of_day = (new_hour % 24) * 60;
}
static bool values_contain(const int *values, size_t n, long long x) {
for (size_t i = 0; i < n; i++) {
if (values[i] == 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 true and writes *out; returns false if the
* expression is invalid or no firing occurs within ~3 years.
*/
bool next_run(const char *expr, CivilTime after, CivilTime *out) {
char *err = NULL;
ParsedField parts[5];
parse_expr(expr, parts, &err);
if (err != NULL) {
free(err);
return false;
}
const ParsedField *minute = &parts[0], *hour = &parts[1], *dom = &parts[2],
*month = &parts[3], *dow = &parts[4];
bool dom_wild = dom->wildcard;
bool dow_wild = dow->wildcard;
/* Start at the top of the minute following `after`, seconds zeroed. */
UtcCursor cur = cursor_from_civil(after);
cursor_bump_minute(&cur);
long long limit = cur_year(cur) + 3; /* hard stop ~3 years out */
while (cur_year(cur) < limit) {
if (!values_contain(month->values, month->count, cur_month(cur))) {
cursor_advance_month_day1(&cur);
continue;
}
bool dom_ok = values_contain(dom->values, dom->count, cur_day(cur));
bool dow_ok = values_contain(dow->values, dow->count, weekday_from_days(cur.days));
bool day_ok = dom_wild || dow_wild ? (dom_ok && dow_ok) : (dom_ok || dow_ok);
if (!day_ok) {
cursor_advance_day(&cur);
continue;
}
if (!values_contain(hour->values, hour->count, cur.min_of_day / 60)) {
cursor_advance_hour_zero(&cur);
continue;
}
if (!values_contain(minute->values, minute->count, cur.min_of_day % 60)) {
cursor_bump_minute(&cur);
continue;
}
civil_from_days(cur.days, &out->year, &out->month, &out->day);
out->hour = cur.min_of_day / 60;
out->minute = cur.min_of_day % 60;
for (size_t i = 0; i < 5; i++) parsed_field_free(&parts[i]);
return true;
}
for (size_t i = 0; i < 5; i++) parsed_field_free(&parts[i]);
return false;
}
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 →