Skip to content

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 →