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# 12 (.NET 8, 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).
//
// The public surface mirrors the TypeScript reference: ExplainCron,
// BuildCron, NextRun.
using System;
using System.Collections.Generic;
using System.Linq;
namespace CosmoDev.CronExplainer;
// ─── Field model ────────────────────────────────────────────────────────────
//
// The five cron fields in positional order, each with its numeric range and
// whether it accepts named tokens (JAN..DEC / SUN..SAT). WrapMax is true only
// for day-of-week, where 7 is treated as an alias for 0 (Sunday).
/// <summary>Positional name of a cron field.</summary>
public enum CronField
{
Minute,
Hour,
DayOfMonth,
Month,
DayOfWeek
}
public static class CronFieldExtensions
{
/// <summary>The human label used in error messages and pluralised descriptions.</summary>
public static string Label(this CronField field) => field switch
{
CronField.Minute => "minute",
CronField.Hour => "hour",
CronField.DayOfMonth => "day-of-month",
CronField.Month => "month",
CronField.DayOfWeek => "day-of-week",
_ => throw new ArgumentOutOfRangeException(nameof(field)),
};
}
/// <summary>Per-field metadata: numeric range plus parsing rules.</summary>
/// <param name="Name">Positional field identifier.</param>
/// <param name="Min">Inclusive lower bound of the field's numeric range.</param>
/// <param name="Max">Inclusive upper bound of the field's numeric range.</param>
/// <param name="Named">Whether the field accepts JAN..DEC / SUN..SAT tokens.</param>
/// <param name="WrapMax">Max value wraps to min (dow: 7 → 0 / Sunday).</param>
public readonly record struct FieldMeta(CronField Name, int Min, int Max, bool Named, bool WrapMax);
file static class CronTables
{
/// <summary>The positional field table, indexed 0..4.</summary>
public static readonly FieldMeta[] Fields =
[
new(CronField.Minute, 0, 59, Named: false, WrapMax: false),
new(CronField.Hour, 0, 23, Named: false, WrapMax: false),
new(CronField.DayOfMonth, 1, 31, Named: false, WrapMax: false),
new(CronField.Month, 1, 12, Named: true, WrapMax: false),
new(CronField.DayOfWeek, 0, 7, Named: true, WrapMax: true),
];
public static readonly string[] MonthNames =
[
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
];
public static readonly string[] DowNames =
[
"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.
public static readonly (string Token, int Value)[] MonthTokens =
[
("JAN", 1), ("FEB", 2), ("MAR", 3), ("APR", 4), ("MAY", 5), ("JUN", 6),
("JUL", 7), ("AUG", 8), ("SEP", 9), ("OCT", 10), ("NOV", 11), ("DEC", 12),
];
public static readonly (string Token, int Value)[] DowTokens =
[
("SUN", 0), ("MON", 1), ("TUE", 2), ("WED", 3), ("THU", 4), ("FRI", 5), ("SAT", 6),
];
}
/// <summary>Raised for a malformed field (the ValueError analogue).</summary>
public sealed class CronException(string message) : ArgumentException(message);
public static partial class CronExplainer
{
private static string Pad2(int n) => n < 10 ? $"0{n}" : n.ToString();
private static string MonthName(int m) => CronTables.MonthNames[m - 1];
private static string DowName(int d) => CronTables.DowNames[d % 7];
/// <summary>Inclusive integer range, e.g. InclusiveRange(1, 5) → [1, 2, 3, 4, 5].</summary>
private static List<int> InclusiveRange(int lo, int hi) =>
Enumerable.Range(lo, hi - lo + 1).ToList();
/// <summary>Parse a strictly-numeric token (ASCII digits only). Rejects named
/// tokens, signs, and surrounding garbage so malformed fields surface clearly.</summary>
private static int ParseIntStrict(string s, string label)
{
var t = s.Trim();
if (t.Length == 0 || !t.All(c => c is >= '0' and <= '9'))
throw new CronException($"{label}: invalid number \"{s}\"");
return int.Parse(t);
}
/// <summary>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.</summary>
private static string Normalize(string value, FieldMeta meta)
{
var v = value.Trim().ToUpperInvariant();
if (!meta.Named)
return v;
var tokens = meta.Name == CronField.Month ? CronTables.MonthTokens : CronTables.DowTokens;
foreach (var (token, num) in tokens)
v = v.Replace(token, num.ToString(), StringComparison.Ordinal);
return v;
}
/// <summary>A field after expansion: the matched values plus the raw token and a
/// flag distinguishing a bare <c>*</c> (wildcard) from an explicit enumeration.</summary>
private sealed class ParsedField(FieldMeta meta, string raw, List<int> values, bool wildcard)
{
public FieldMeta Meta { get; } = meta;
public string Raw { get; } = raw;
public List<int> Values { get; } = values;
public bool Wildcard { get; } = wildcard;
}
/// <summary>Expand one field value into the explicit set of numbers it matches.
///
/// Handles <c>*</c>, <c>*/N</c>, <c>A-B</c>, <c>A-B/N</c>, <c>A</c> (single),
/// <c>A/N</c> (A to field max), and comma-separated lists of any of these.
/// Returns the deduped, sorted values plus a wildcard flag for a bare <c>*</c>.</summary>
private static ParsedField ExpandField(string value, FieldMeta meta)
{
var norm = Normalize(value, meta);
if (norm.Length == 0)
throw new CronException($"{meta.Name.Label()}: empty field");
if (norm == "*")
return new ParsedField(meta, value, InclusiveRange(meta.Min, meta.Max), wildcard: true);
var set = new List<int>();
foreach (var term in norm.Split(','))
{
if (term.Length == 0)
throw new CronException($"{meta.Name.Label()}: empty list item");
var slashIdx = term.IndexOf('/');
var @base = term;
var step = 1;
if (slashIdx != -1)
{
@base = term[..slashIdx];
step = ParseIntStrict(term[(slashIdx + 1)..], meta.Name.Label());
if (step <= 0)
throw new CronException($"{meta.Name.Label()}: step must be a positive number");
}
int lo, hi;
if (@base == "*")
{
lo = meta.Min;
hi = meta.Max;
}
else if (@base.IndexOf('-') is int dash && dash != -1)
{
lo = ParseIntStrict(@base[..dash], meta.Name.Label());
hi = ParseIntStrict(@base[(dash + 1)..], meta.Name.Label());
}
else
{
lo = ParseIntStrict(@base, meta.Name.Label());
// "A/step" runs from A to the field max; a bare "A" is a single value.
hi = slashIdx != -1 ? meta.Max : lo;
}
if (lo > hi)
throw new CronException($"{meta.Name.Label()}: range start {lo} is greater than end {hi}");
if (lo < meta.Min)
throw new CronException($"{meta.Name.Label()}: value {lo} is below minimum {meta.Min}");
if (hi > meta.Max)
throw new CronException($"{meta.Name.Label()}: value {hi} is above maximum {meta.Max}");
for (var v = lo; v <= hi; v += step)
{
var resolved = meta.WrapMax && v == meta.Max ? meta.Min : v;
if (!set.Contains(resolved))
set.Add(resolved);
}
}
set.Sort();
return new ParsedField(meta, value, set, wildcard: false);
}
/// <summary>Parse all five fields, or throw a <see cref="CronException"/>
/// carrying the human-readable error.</summary>
private static List<ParsedField> ParseExpr(string expr)
{
var tokens = expr.Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
if (tokens.Length != 5)
throw new CronException(
$"Expected 5 fields (minute hour day-of-month month day-of-week), got {tokens.Length}");
return Enumerable.Range(0, 5)
.Select(i => ExpandField(tokens[i], CronTables.Fields[i]))
.ToList();
}
/// <summary>True when a sorted value list is a contiguous run (e.g. [3, 4, 5, 6]).</summary>
private static bool IsContiguous(IReadOnlyList<int> values)
{
for (var i = 1; i < values.Count; i++)
if (values[i] - values[i - 1] != 1)
return false;
return true;
}
/// <summary>Describe a single value in the field's own vocabulary.</summary>
private static string SingleValue(int n, FieldMeta meta) => meta.Name switch
{
CronField.Minute => $"minute {n}",
CronField.Hour => $"hour {n}",
CronField.DayOfMonth => $"day {n} of the month",
CronField.Month => MonthName(n),
CronField.DayOfWeek => DowName(n),
_ => throw new ArgumentOutOfRangeException(nameof(meta)),
};
/// <summary>Describe a parsed field as a human phrase (no leading preposition).
/// <see cref="ParsedField.Raw"/> distinguishes step syntax (star/N or A-B/N)
/// from plain lists, since two raw forms can expand to the same value set.</summary>
private static string DescribeField(ParsedField p)
{
var meta = p.Meta;
var values = p.Values;
if (p.Wildcard)
return meta.Name switch
{
CronField.Minute => "every minute",
CronField.Hour => "every hour",
CronField.DayOfMonth => "every day of the month",
CronField.Month => "every month",
CronField.DayOfWeek => "every day of the week",
_ => throw new ArgumentOutOfRangeException(),
};
// Step syntax is reported as "every N <units>".
var slashIdx = p.Raw.IndexOf('/');
if (slashIdx != -1 && values.Count > 0)
{
var step = 1;
try
{
step = ParseIntStrict(p.Raw[(slashIdx + 1)..], meta.Name.Label());
}
catch (CronException)
{
step = 1; // multi-term raw ("*/5,10-20/3") — fall back like the Rust port
}
var start = values[0];
var unitPlural = meta.Name switch
{
CronField.DayOfMonth => "days of the month",
CronField.DayOfWeek => "days of the week",
_ => $"{meta.Name.Label()}s",
};
return start == meta.Min
? $"every {step} {unitPlural}"
: $"every {step} {unitPlural} starting at {SingleValue(start, meta)}";
}
if (values.Count == 1)
return SingleValue(values[0], meta);
if (IsContiguous(values))
{
var (a, b) = (values[0], values[^1]);
if (meta.Name == CronField.Month)
return $"{MonthName(a)} through {MonthName(b)}";
if (meta.Name == CronField.DayOfWeek)
return $"{DowName(a)} through {DowName(b)}";
var unitPlural = meta.Name == CronField.DayOfMonth ? "days" : $"{meta.Name.Label()}s";
return $"{unitPlural} {a} through {b}";
}
// Explicit list of discrete values.
var joined = string.Join(", ", values);
return meta.Name switch
{
CronField.Month => string.Join(", ", values.Select(MonthName)),
CronField.DayOfWeek => string.Join(", ", values.Select(DowName)),
CronField.Minute => $"minutes {joined}",
CronField.Hour => $"hours {joined}",
CronField.DayOfMonth => $"days {joined} of the month",
_ => throw new ArgumentOutOfRangeException(),
};
}
/// <summary>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 …").</summary>
private static string Prepend(string prefix, string phrase) =>
phrase.StartsWith("every", StringComparison.Ordinal) ? phrase : $"{prefix} {phrase}";
/// <summary>Compose the opening time-of-day clause from minute and hour fields.</summary>
private static string TimeClause(ParsedField minute, ParsedField hour)
{
var mAll = minute.Wildcard;
var hAll = hour.Wildcard;
var mSingle = !mAll && minute.Values.Count == 1;
var hSingle = !hAll && hour.Values.Count == 1;
if (mAll && hAll)
return "Every minute";
if (mAll && hSingle)
return $"Every minute of hour {hour.Values[0]}";
if (mSingle && hAll)
return $"At minute {minute.Values[0]} of every hour";
if (mSingle && hSingle)
return $"At {Pad2(hour.Values[0])}:{Pad2(minute.Values[0])}";
// Mixed: describe each non-wildcard field, hour first.
var clauses = new List<string>();
if (!hAll)
clauses.Add(DescribeField(hour));
if (!mAll)
clauses.Add(DescribeField(minute));
var s = string.Join(", ", clauses);
return char.ToUpperInvariant(s[0]) + s[1..];
}
private static string ComposeDescription(List<ParsedField> parts)
{
var (minute, hour, dom, month, dow) = (parts[0], parts[1], parts[2], parts[3], parts[4]);
var clauses = new List<string> { TimeClause(minute, hour) };
if (!dom.Wildcard)
clauses.Add(Prepend("on", DescribeField(dom)));
if (!month.Wildcard)
clauses.Add(Prepend("in", DescribeField(month)));
if (!dow.Wildcard)
clauses.Add(Prepend("on", DescribeField(dow)));
return string.Join(", ", clauses);
}
// ─── Public API ──────────────────────────────────────────────────────────
/// <summary>One entry of the per-field explanation.</summary>
/// <param name="Field">One of the five positional field names.</param>
/// <param name="Value">Raw field value as written in the expression.</param>
/// <param name="Meaning">Human-readable description of what this field matches.</param>
public sealed record CronFieldInfo(string Field, string Value, string Meaning);
/// <summary>The result of <see cref="ExplainCron"/>.</summary>
/// <param name="Valid">Whether the expression parsed.</param>
/// <param name="Description">Plain-English description; "" when invalid.</param>
/// <param name="Fields">One entry per field; empty when invalid.</param>
/// <param name="Error">Present only when <paramref name="Valid"/> is false.</param>
public sealed record CronExplanation(
bool Valid,
string Description,
IReadOnlyList<CronFieldInfo> Fields,
string? Error)
{
internal static CronExplanation Invalid(string error) =>
new(false, "", [], error);
}
/// <summary>Parse and explain a 5-field cron expression in plain English.
///
/// <c>CronExplainer.ExplainCron("30 14 * * *").Description</c> → "At 14:30".</summary>
public static CronExplanation ExplainCron(string expr)
{
try
{
var parts = ParseExpr(expr);
var fields = parts
.Select(p => new CronFieldInfo(p.Meta.Name.Label(), p.Raw, DescribeField(p)))
.ToList();
return new CronExplanation(true, ComposeDescription(parts), fields, null);
}
catch (CronException e)
{
return CronExplanation.Invalid(e.Message);
}
}
/// <summary>Per-field specs for <see cref="BuildCron"/>. Null/empty fields default to <c>*</c>.</summary>
public sealed record BuildCronOptions(
string? Minute = null,
string? Hour = null,
string? Dom = null,
string? Month = null,
string? Dow = null);
/// <summary>Assemble a 5-field cron expression from per-field specs. Each field
/// defaults to <c>*</c> when empty/omitted; invalid fields throw
/// <see cref="CronException"/> so callers cannot build a malformed expression.
///
/// <c>BuildCron(new(Minute: "30", Hour: "14"))</c> → "30 14 * * *".</summary>
public static string BuildCron(BuildCronOptions? opts = null)
{
opts ??= new BuildCronOptions();
var specs = new (FieldMeta Meta, string? Value)[]
{
(CronTables.Fields[0], opts.Minute),
(CronTables.Fields[1], opts.Hour),
(CronTables.Fields[2], opts.Dom),
(CronTables.Fields[3], opts.Month),
(CronTables.Fields[4], opts.Dow),
};
var @out = new List<string>(5);
foreach (var (meta, value) in specs)
{
var v = (value ?? "").Trim();
if (v.Length == 0)
{
@out.Add("*");
continue;
}
_ = ExpandField(v, meta); // validates; throws on bad input
@out.Add(v);
}
return string.Join(' ', @out);
}
/// <summary>Next time the expression fires, strictly after <paramref name="after"/>,
/// evaluated in UTC. A <see cref="DateTime"/> with <see cref="DateTimeKind.Local"/>
/// is converted to UTC; Unspecified is treated as UTC (the caller controls
/// the instant).
///
/// Implements standard Vixie-cron day matching: when BOTH day-of-month and
/// day-of-week are restricted, a match on either suffices (OR); otherwise
/// both must match (AND). Returns null if no firing occurs within ~3 years.</summary>
public static DateTime? NextRun(string expr, DateTime after)
{
List<ParsedField> parts;
try
{
parts = ParseExpr(expr);
}
catch (CronException)
{
return null;
}
var (minute, hour, dom, month, dow) = (parts[0], parts[1], parts[2], parts[3], parts[4]);
var mSet = minute.Values.ToHashSet();
var hSet = hour.Values.ToHashSet();
var domSet = dom.Values.ToHashSet();
var monSet = month.Values.ToHashSet();
var dowSet = dow.Values.ToHashSet();
var domWild = dom.Wildcard;
var dowWild = dow.Wildcard;
var cur = after.Kind == DateTimeKind.Local
? after.ToUniversalTime()
: DateTime.SpecifyKind(after, DateTimeKind.Utc);
// Start at the top of the minute following `after`, seconds zeroed.
cur = cur.Date.AddHours(cur.Hour).AddMinutes(cur.Minute + 1);
var limit = cur.Year + 3; // hard stop ~3 years out
while (cur.Year < limit)
{
if (!monSet.Contains(cur.Month))
{
// Advance to day 1 of next month, midnight.
cur = cur.Month == 12
? new DateTime(cur.Year + 1, 1, 1, 0, 0, 0, DateTimeKind.Utc)
: new DateTime(cur.Year, cur.Month + 1, 1, 0, 0, 0, DateTimeKind.Utc);
continue;
}
var domOk = domSet.Contains(cur.Day);
// DateTime.DayOfWeek is already 0=Sunday..6=Saturday — cron's convention.
var dowOk = dowSet.Contains((int)cur.DayOfWeek);
var dayOk = domWild || dowWild ? domOk && dowOk : domOk || dowOk;
if (!dayOk)
{
cur = cur.Date.AddDays(1);
continue;
}
if (!hSet.Contains(cur.Hour))
{
cur = cur.Date.AddHours(cur.Hour + 1); // next hour, minute zeroed
continue;
}
if (!mSet.Contains(cur.Minute))
{
cur = cur.AddMinutes(1);
continue;
}
return cur;
}
return null;
}
}
Also available in 13 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →