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 →