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++17 polyglot showcase port.
//
// Language: C++17 (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 (no stdlib civil calendar in C++17): std::chrono grew
// year/month/day in C++20, 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 int64_t; 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 <algorithm>
#include <cctype>
#include <cstdint>
#include <optional>
#include <stdexcept>
#include <string>
#include <tuple>
#include <utility>
#include <vector>

namespace cron_explainer {

// ─── 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.
enum class FieldName { Minute, Hour, DayOfMonth, Month, DayOfWeek };

/// The human label used in error messages and pluralised descriptions.
inline const char* field_label(FieldName f) {
    switch (f) {
        case FieldName::Minute: return "minute";
        case FieldName::Hour: return "hour";
        case FieldName::DayOfMonth: return "day-of-month";
        case FieldName::Month: return "month";
        case FieldName::DayOfWeek: return "day-of-week";
    }
    return "";  // unreachable
}

/// Per-field metadata: numeric range plus parsing rules.
struct FieldMeta {
    FieldName name;
    int64_t min;
    int64_t max;
    bool named;
    bool wrap_max;
};

/// The positional field table, indexed 0..4.
inline constexpr FieldMeta FIELDS[5] = {
    {FieldName::Minute, 0, 59, false, false},
    {FieldName::Hour, 0, 23, false, false},
    {FieldName::DayOfMonth, 1, 31, false, false},
    {FieldName::Month, 1, 12, true, false},
    {FieldName::DayOfWeek, 0, 7, true, true},
};

inline const char* const MONTH_NAMES[12] = {
    "January", "February", "March", "April", "May", "June",
    "July", "August", "September", "October", "November", "December",
};
inline 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.
struct TokenValue { const char* token; int64_t value; };
inline const TokenValue 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},
};
inline const TokenValue DOW_TOKENS[7] = {
    {"SUN", 0}, {"MON", 1}, {"TUE", 2}, {"WED", 3}, {"THU", 4}, {"FRI", 5}, {"SAT", 6},
};

/// Raised for a malformed field (the Python port's CronError is a ValueError).
class CronError : public std::runtime_error {
  public:
    using std::runtime_error::runtime_error;
};

inline std::string pad2(int64_t n) {
    return n < 10 ? "0" + std::to_string(n) : std::to_string(n);
}

inline std::string month_name(int64_t m) {
    return MONTH_NAMES[static_cast<size_t>(m - 1)];
}

inline std::string dow_name(int64_t d) {
    return DOW_NAMES[static_cast<size_t>(d % 7)];
}

/// Inclusive integer range, e.g. inclusive_range(1, 5) -> [1, 2, 3, 4, 5].
inline std::vector<int64_t> inclusive_range(int64_t lo, int64_t hi) {
    std::vector<int64_t> out;
    out.reserve(static_cast<size_t>(hi - lo + 1));
    for (int64_t v = lo; v <= hi; ++v) out.push_back(v);
    return out;
}

inline bool is_digits(const std::string& s) {
    return !s.empty() && std::all_of(s.begin(), s.end(),
                                     [](unsigned char c) { return c >= '0' && c <= '9'; });
}

/// Parse a strictly-numeric token (ASCII digits only). Rejects named tokens,
/// signs, and surrounding garbage so malformed fields surface clearly.
inline int64_t parse_int_strict(const std::string& s, const std::string& label) {
    // Trim ASCII whitespace like the reference ports' str.strip().
    size_t b = s.find_first_not_of(" \t\r\n");
    if (b == std::string::npos) throw CronError(label + ": invalid number \"" + s + "\"");
    size_t e = s.find_last_not_of(" \t\r\n");
    std::string t = s.substr(b, e - b + 1);
    if (!is_digits(t)) throw CronError(label + ": invalid number \"" + s + "\"");
    return std::stoll(t);
}

inline std::string to_upper(const std::string& s) {
    std::string out = s;
    std::transform(out.begin(), out.end(), out.begin(),
                   [](unsigned char c) { return static_cast<char>(std::toupper(c)); });
    return out;
}

inline std::string replace_all(std::string hay, const std::string& needle,
                               const std::string& repl) {
    if (needle.empty()) return hay;
    std::string out;
    size_t pos = 0;
    while (true) {
        size_t hit = hay.find(needle, pos);
        if (hit == std::string::npos) {
            out += hay.substr(pos);
            break;
        }
        out += hay.substr(pos, hit - pos);
        out += repl;
        pos = hit + needle.size();
    }
    return out;
}

/// 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.
inline std::string normalize(const std::string& value, const FieldMeta& meta) {
    std::string v = to_upper(value);
    // (leading/trailing whitespace was never admitted: fields come from
    // whitespace-split tokens, and build_cron trims before validating)
    if (!meta.named) return v;
    const bool is_month = meta.name == FieldName::Month;
    const TokenValue* tokens = is_month ? MONTH_TOKENS : DOW_TOKENS;
    const size_t n = is_month ? 12 : 7;
    for (size_t i = 0; i < n; ++i) {
        v = replace_all(std::move(v), tokens[i].token, std::to_string(tokens[i].value));
    }
    return v;
}

/// A field after expansion: the matched values plus the raw token and a flag
/// distinguishing a bare `*` (wildcard) from an explicit enumeration.
struct ParsedField {
    FieldMeta meta;
    std::string raw;
    std::vector<int64_t> values;
    bool wildcard;
};

/// 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 `*`.
inline ParsedField expand_field(const std::string& value, const FieldMeta& meta) {
    const std::string label = field_label(meta.name);
    std::string norm = normalize(value, meta);
    if (norm.empty()) throw CronError(label + ": empty field");
    if (norm == "*") {
        return ParsedField{meta, value, inclusive_range(meta.min, meta.max), true};
    }

    std::vector<int64_t> set;
    size_t pos = 0;
    while (true) {
        size_t comma = norm.find(',', pos);
        std::string term = norm.substr(pos, comma == std::string::npos ? std::string::npos
                                                                       : comma - pos);
        if (term.empty()) throw CronError(label + ": empty list item");

        size_t slash = term.find('/');
        std::string base = term;
        int64_t step = 1;
        if (slash != std::string::npos) {
            base = term.substr(0, slash);
            step = parse_int_strict(term.substr(slash + 1), label);
            if (step <= 0) throw CronError(label + ": step must be a positive number");
        }

        int64_t lo, hi;
        if (base == "*") {
            lo = meta.min;
            hi = meta.max;
        } else {
            size_t dash = base.find('-');
            if (dash != std::string::npos) {
                lo = parse_int_strict(base.substr(0, dash), label);
                hi = parse_int_strict(base.substr(dash + 1), label);
            } else {
                lo = parse_int_strict(base, label);
                // "A/step" runs from A to the field max; a bare "A" is a single value.
                hi = slash != std::string::npos ? meta.max : lo;
            }
        }

        if (lo > hi) throw CronError(label + ": range start " + std::to_string(lo) +
                                     " is greater than end " + std::to_string(hi));
        if (lo < meta.min)
            throw CronError(label + ": value " + std::to_string(lo) +
                            " is below minimum " + std::to_string(meta.min));
        if (hi > meta.max)
            throw CronError(label + ": value " + std::to_string(hi) +
                            " is above maximum " + std::to_string(meta.max));

        for (int64_t v = lo; v <= hi; v += step) {
            int64_t resolved = (meta.wrap_max && v == meta.max) ? meta.min : v;
            if (std::find(set.begin(), set.end(), resolved) == set.end()) {
                set.push_back(resolved);
            }
        }

        if (comma == std::string::npos) break;
        pos = comma + 1;
    }

    std::sort(set.begin(), set.end());
    return ParsedField{meta, value, std::move(set), false};
}

inline std::string trim(const std::string& s) {
    size_t b = s.find_first_not_of(" \t\r\n");
    if (b == std::string::npos) return "";
    size_t e = s.find_last_not_of(" \t\r\n");
    return s.substr(b, e - b + 1);
}

/// Parse all five fields, or throw a CronError carrying the human-readable
/// error.
inline std::vector<ParsedField> parse_expr(const std::string& expr) {
    std::vector<std::string> tokens;
    std::string t;
    for (char c : expr) {
        if (std::isspace(static_cast<unsigned char>(c))) {
            if (!t.empty()) {
                tokens.push_back(t);
                t.clear();
            }
        } else {
            t += c;
        }
    }
    if (!t.empty()) tokens.push_back(t);

    if (tokens.size() != 5) {
        throw CronError("Expected 5 fields (minute hour day-of-month month day-of-week), got " +
                        std::to_string(tokens.size()));
    }
    std::vector<ParsedField> parts;
    parts.reserve(5);
    for (size_t i = 0; i < 5; ++i) {
        parts.push_back(expand_field(tokens[i], FIELDS[i]));
    }
    return parts;
}

/// True when a sorted value list is a contiguous run (e.g. [3, 4, 5, 6]).
inline bool is_contiguous(const std::vector<int64_t>& values) {
    for (size_t i = 1; i < values.size(); ++i) {
        if (values[i] - values[i - 1] != 1) return false;
    }
    return true;
}

inline std::string join(const std::vector<std::string>& parts, const std::string& sep) {
    std::string out;
    for (size_t i = 0; i < parts.size(); ++i) {
        if (i > 0) out += sep;
        out += parts[i];
    }
    return out;
}

/// Describe a single value in the field's own vocabulary.
inline std::string single_value(int64_t n, const FieldMeta& meta) {
    switch (meta.name) {
        case FieldName::Minute: return "minute " + std::to_string(n);
        case FieldName::Hour: return "hour " + std::to_string(n);
        case FieldName::DayOfMonth: return "day " + std::to_string(n) + " of the month";
        case FieldName::Month: return month_name(n);
        case FieldName::DayOfWeek: return dow_name(n);
    }
    return "";  // unreachable
}

/// 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.
inline std::string describe_field(const ParsedField& p) {
    const FieldMeta& meta = p.meta;
    const std::vector<int64_t>& values = p.values;
    const std::string label = field_label(meta.name);

    if (p.wildcard) {
        switch (meta.name) {
            case FieldName::Minute: return "every minute";
            case FieldName::Hour: return "every hour";
            case FieldName::DayOfMonth: return "every day of the month";
            case FieldName::Month: return "every month";
            case FieldName::DayOfWeek: return "every day of the week";
        }
    }

    // Step syntax is reported as "every N <units>".
    size_t slash = p.raw.find('/');
    if (slash != std::string::npos && !values.empty()) {
        int64_t step = 1;
        try {
            step = parse_int_strict(p.raw.substr(slash + 1), label);
        } catch (const CronError&) {
            step = 1;  // multi-term raw ("*/5,10-20/3") — fall back like the Rust port
        }
        int64_t start = values.front();
        std::string unit_plural;
        switch (meta.name) {
            case FieldName::DayOfMonth: unit_plural = "days of the month"; break;
            case FieldName::DayOfWeek: unit_plural = "days of the week"; break;
            default: unit_plural = std::string(label) + "s"; break;
        }
        if (start == meta.min) return "every " + std::to_string(step) + " " + unit_plural;
        return "every " + std::to_string(step) + " " + unit_plural + " starting at " +
               single_value(start, meta);
    }

    if (values.size() == 1) return single_value(values.front(), meta);

    if (is_contiguous(values)) {
        int64_t a = values.front();
        int64_t b = values.back();
        if (meta.name == FieldName::Month) return month_name(a) + " through " + month_name(b);
        if (meta.name == FieldName::DayOfWeek) return dow_name(a) + " through " + dow_name(b);
        std::string unit_plural =
            meta.name == FieldName::DayOfMonth ? "days" : std::string(label) + "s";
        return unit_plural + " " + std::to_string(a) + " through " + std::to_string(b);
    }

    // Explicit list of discrete values.
    std::vector<std::string> as_strings;
    for (int64_t v : values) as_strings.push_back(std::to_string(v));
    std::string joined = join(as_strings, ", ");
    switch (meta.name) {
        case FieldName::Month: {
            std::vector<std::string> names;
            for (int64_t v : values) names.push_back(month_name(v));
            return join(names, ", ");
        }
        case FieldName::DayOfWeek: {
            std::vector<std::string> names;
            for (int64_t v : values) names.push_back(dow_name(v));
            return join(names, ", ");
        }
        case FieldName::Minute: return "minutes " + joined;
        case FieldName::Hour: return "hours " + joined;
        case FieldName::DayOfMonth: return "days " + joined + " of the month";
    }
    return "";  // 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 ...").
inline std::string prepend(const std::string& prefix, const std::string& phrase) {
    return phrase.rfind("every", 0) == 0 ? phrase : prefix + " " + phrase;
}

/// Compose the opening time-of-day clause from the minute and hour fields.
inline std::string time_clause(const ParsedField& minute, const ParsedField& hour) {
    bool m_all = minute.wildcard;
    bool h_all = hour.wildcard;
    bool m_single = !m_all && minute.values.size() == 1;
    bool h_single = !h_all && hour.values.size() == 1;

    if (m_all && h_all) return "Every minute";
    if (m_all && h_single) return "Every minute of hour " + std::to_string(hour.values.front());
    if (m_single && h_all)
        return "At minute " + std::to_string(minute.values.front()) + " of every hour";
    if (m_single && h_single) {
        return "At " + pad2(hour.values.front()) + ":" + pad2(minute.values.front());
    }

    // Mixed: describe each non-wildcard field, hour first.
    std::vector<std::string> clauses;
    if (!h_all) clauses.push_back(describe_field(hour));
    if (!m_all) clauses.push_back(describe_field(minute));
    std::string s = join(clauses, ", ");
    if (!s.empty()) s[0] = static_cast<char>(std::toupper(static_cast<unsigned char>(s[0])));
    return s;
}

inline std::string compose_description(const std::vector<ParsedField>& parts) {
    const ParsedField& minute = parts[0];
    const ParsedField& hour = parts[1];
    const ParsedField& dom = parts[2];
    const ParsedField& month = parts[3];
    const ParsedField& dow = parts[4];
    std::vector<std::string> clauses;
    clauses.push_back(time_clause(minute, hour));
    if (!dom.wildcard) clauses.push_back(prepend("on", describe_field(dom)));
    if (!month.wildcard) clauses.push_back(prepend("in", describe_field(month)));
    if (!dow.wildcard) clauses.push_back(prepend("on", describe_field(dow)));
    return join(clauses, ", ");
}

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

/// One entry of the per-field explanation.
struct CronFieldInfo {
    std::string field;   // one of the five positional field names
    std::string value;   // raw field value as written in the expression
    std::string meaning; // human-readable description of what this field matches
};

/// The result of explain_cron().
struct CronExplanation {
    bool valid = false;
    std::string description;              // "" when invalid
    std::vector<CronFieldInfo> fields;    // one per field; empty when invalid
    std::string error;                    // present only when valid is false
};

/// Parse and explain a 5-field cron expression in plain English.
///
///     cron_explainer::explain_cron("30 14 * * *").description  // "At 14:30"
inline CronExplanation explain_cron(const std::string& expr) {
    try {
        std::vector<ParsedField> parts = parse_expr(expr);
        CronExplanation out;
        out.valid = true;
        out.description = compose_description(parts);
        for (const ParsedField& p : parts) {
            out.fields.push_back(
                CronFieldInfo{field_label(p.meta.name), p.raw, describe_field(p)});
        }
        return out;
    } catch (const CronError& e) {
        CronExplanation out;
        out.error = e.what();
        return out;
    }
}

/// Per-field specs for build_cron(). Empty fields default to `*`.
struct BuildCronOptions {
    std::string minute;
    std::string hour;
    std::string dom;
    std::string month;
    std::string dow;
};

/// Assemble a 5-field cron expression from per-field specs. Each field
/// defaults to `*` when empty/omitted; invalid fields throw CronError so
/// callers cannot build a malformed expression.
///
///     build_cron(BuildCronOptions{"30", "14", "", "", ""})  // "30 14 * * *"
inline std::string build_cron(const BuildCronOptions& opts = {}) {
    const std::pair<FieldMeta, std::string> specs[5] = {
        {FIELDS[0], opts.minute}, {FIELDS[1], opts.hour}, {FIELDS[2], opts.dom},
        {FIELDS[3], opts.month}, {FIELDS[4], opts.dow},
    };
    std::vector<std::string> out;
    out.reserve(5);
    for (const auto& [meta, value] : specs) {
        std::string v = trim(value);
        if (v.empty()) {
            out.push_back("*");
            continue;
        }
        expand_field(v, meta);  // validates; throws on bad input
        out.push_back(v);
    }
    return join(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 C++'s (toward-zero) integer division.

inline int64_t days_from_civil(int64_t y, int64_t m, int64_t d) {
    if (m <= 2) y -= 1;
    int64_t era = (y >= 0 ? y : y - 399) / 400;
    int64_t yoe = y - era * 400;                                        // [0, 399]
    int64_t doy = (153 * (m > 2 ? m - 3 : m + 9) + 2) / 5 + d - 1;      // [0, 365]
    int64_t doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;                // [0, 146096]
    return era * 146097 + doe - 719468;
}

inline std::tuple<int64_t, int64_t, int64_t> civil_from_days(int64_t z) {
    z += 719468;
    int64_t era = (z >= 0 ? z : z - 146096) / 146097;
    int64_t doe = z - era * 146097;                                     // [0, 146096]
    int64_t yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; // [0, 399]
    int64_t y = yoe + era * 400;
    int64_t doy = doe - (365 * yoe + yoe / 4 - yoe / 100);              // [0, 365]
    int64_t mp = (5 * doy + 2) / 153;                                   // [0, 11]
    int64_t d = doy - (153 * mp + 2) / 5 + 1;                           // [1, 31]
    int64_t m = mp < 10 ? mp + 3 : mp - 9;                              // [1, 12]
    if (m <= 2) y += 1;
    return {y, m, d};
}

/// Weekday (0 = Sunday .. 6 = Saturday) for a serial day count.
/// 1970-01-01 was a Thursday (4), which anchors the +11 offset.
inline int64_t weekday_from_days(int64_t 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.
struct CivilTime {
    int64_t year;
    int64_t month;   // 1..=12
    int64_t day;     // 1..=31
    int64_t hour;    // 0..=23
    int64_t minute;  // 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).
struct UtcCursor {
    int64_t days;
    int64_t min_of_day;  // 0..=1439

    static UtcCursor from_civil(const CivilTime& c) {
        return UtcCursor{days_from_civil(c.year, c.month, c.day), c.hour * 60 + c.minute};
    }

    int64_t year() const { return std::get<0>(civil_from_days(days)); }
    int64_t month() const { return std::get<1>(civil_from_days(days)); }
    int64_t day() const { return std::get<2>(civil_from_days(days)); }
    int64_t weekday() const { return weekday_from_days(days); }
    int64_t hour() const { return min_of_day / 60; }
    int64_t minute() const { return min_of_day % 60; }

    /// +1 minute, seconds conceptually zero (sub-minute is never tracked).
    void bump_minute() {
        min_of_day += 1;
        if (min_of_day >= 1440) {
            min_of_day -= 1440;
            days += 1;
        }
    }

    /// setUTCMonth(+1, 1) + zero time → first day of next month, midnight.
    void advance_month_day1() {
        auto [y, m, d] = civil_from_days(days);
        (void)d;
        int64_t ny = m == 12 ? y + 1 : y;
        int64_t nm = m == 12 ? 1 : m + 1;
        days = days_from_civil(ny, nm, 1);
        min_of_day = 0;
    }

    /// setUTCDate(+1) + zero time → next day, midnight.
    void advance_day() {
        days += 1;
        min_of_day = 0;
    }

    /// setUTCHours(+1, 0, 0, 0) → next hour with minute zeroed (may roll day).
    void advance_hour_zero() {
        int64_t new_hour = min_of_day / 60 + 1;
        days += new_hour / 24;
        min_of_day = (new_hour % 24) * 60;
    }

    CivilTime to_civil() const {
        auto [y, m, d] = civil_from_days(days);
        return CivilTime{y, m, d, hour(), minute()};
    }
};

/// 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 std::nullopt if no firing occurs within ~3 years.
inline std::optional<CivilTime> next_run(const std::string& expr, CivilTime after) {
    std::vector<ParsedField> parts;
    try {
        parts = parse_expr(expr);
    } catch (const CronError&) {
        return std::nullopt;
    }
    const ParsedField& minute = parts[0];
    const ParsedField& hour = parts[1];
    const ParsedField& dom = parts[2];
    const ParsedField& month = parts[3];
    const ParsedField& dow = parts[4];

    // Sets for O(1) membership tests (sorted vectors; small enough that
    // std::binary_search is plenty and stays allocation-light).
    auto contains = [](const std::vector<int64_t>& v, int64_t x) {
        return std::binary_search(v.begin(), v.end(), x);
    };
    bool dom_wild = dom.wildcard;
    bool dow_wild = dow.wildcard;

    // Start at the top of the minute following `after`, seconds zeroed.
    UtcCursor cur = UtcCursor::from_civil(after);
    cur.bump_minute();

    int64_t limit = cur.year() + 3;  // hard stop ~3 years out
    while (cur.year() < limit) {
        if (!contains(month.values, cur.month())) {
            cur.advance_month_day1();
            continue;
        }
        bool dom_ok = contains(dom.values, cur.day());
        bool dow_ok = contains(dow.values, cur.weekday());  // 0 = Sunday .. 6 = Saturday
        bool day_ok = dom_wild || dow_wild ? dom_ok && dow_ok : dom_ok || dow_ok;
        if (!day_ok) {
            cur.advance_day();
            continue;
        }
        if (!contains(hour.values, cur.hour())) {
            cur.advance_hour_zero();
            continue;
        }
        if (!contains(minute.values, cur.minute())) {
            cur.bump_minute();
            continue;
        }
        return cur.to_civil();
    }
    return std::nullopt;
}

}  // namespace cron_explainer

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 →