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# 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 →