Cron Expression Explainer — Go 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 Go implementation — the same logic the interactive tool runs, in a shareable, citable form.
// Package cronexplainer is the Go twin of CosmoDev's src/lib/cron-explainer.ts
// (dual source: the web lib is TypeScript, the CLI lib is Go — kept in lock-step).
// It is a pure 5-field cron parser, natural-language explainer, builder, and
// next-run calculator. Zero deps, deterministic, never panics. The table-driven
// tests in cron-explainer_test.go share vectors with src/lib/cron-explainer.test.ts
// so the two implementations are held to the same contract.
//
// Times are interpreted as UTC so results are unambiguous and DST-independent (the
// caller controls the instant), exactly like the TS lib.
package cronexplainer
import (
"fmt"
"sort"
"strconv"
"strings"
"time"
)
// CronFieldName names one of the five cron fields, in order.
type CronFieldName string
const (
FieldMinute CronFieldName = "minute"
FieldHour CronFieldName = "hour"
FieldDayOfMonth CronFieldName = "day-of-month"
FieldMonth CronFieldName = "month"
FieldDayOfWeek CronFieldName = "day-of-week"
)
// CronFieldInfo is one entry in a CronExplanation: the raw field value plus a
// human-readable description of what it matches.
type CronFieldInfo struct {
Field CronFieldName
Value string // raw field value as written in the expression
Meaning string // human-readable description of what this field matches
}
// CronExplanation is the result of ExplainCron. When Valid is false, Description
// is "" and Fields is empty; Error holds the reason.
type CronExplanation struct {
Valid bool
Description string
Fields []CronFieldInfo
Error string // present only when Valid is false
}
// BuildCronOptions assembles a 5-field cron expression from user-friendly
// per-field specs. Each field defaults to "*" when empty (the zero value),
// matching the TS lib's omitted/empty → "*" behavior.
type BuildCronOptions struct {
Minute string
Hour string
Dom string
Month string
Dow string
}
// fieldMeta describes one cron field's validation rules. Mirrors FieldMeta in
// the TS lib; label equals the field name (used in error messages).
type fieldMeta struct {
name CronFieldName
label string
min int
max int
named bool // accepts JAN..DEC / SUN..SAT tokens
wrapMax bool // when true, max wraps to min (dow: 7 → 0 / Sunday)
}
var fields = []fieldMeta{
{FieldMinute, "minute", 0, 59, false, false},
{FieldHour, "hour", 0, 23, false, false},
{FieldDayOfMonth, "day-of-month", 1, 31, false, false},
{FieldMonth, "month", 1, 12, true, false},
{FieldDayOfWeek, "day-of-week", 0, 7, true, true},
}
var monthNames = []string{
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
}
var dowNames = []string{
"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday",
}
var monthTokens = map[string]int{
"JAN": 1, "FEB": 2, "MAR": 3, "APR": 4, "MAY": 5, "JUN": 6,
"JUL": 7, "AUG": 8, "SEP": 9, "OCT": 10, "NOV": 11, "DEC": 12,
}
var dowTokens = map[string]int{
"SUN": 0, "MON": 1, "TUE": 2, "WED": 3, "THU": 4, "FRI": 5, "SAT": 6,
}
func monthName(m int) string { return monthNames[m-1] }
func dowName(d int) string { return dowNames[d%7] }
func pad2(n int) string { return fmt.Sprintf("%02d", n) }
func joinInts(vs []int) string {
var b strings.Builder
for i, v := range vs {
if i > 0 {
b.WriteString(", ")
}
b.WriteString(strconv.Itoa(v))
}
return b.String()
}
func joinMonthNames(vs []int) string {
names := make([]string, len(vs))
for i, v := range vs {
names[i] = monthName(v)
}
return strings.Join(names, ", ")
}
func joinDowNames(vs []int) string {
names := make([]string, len(vs))
for i, v := range vs {
names[i] = dowName(v)
}
return strings.Join(names, ", ")
}
func makeRange(lo, hi int) []int {
out := make([]int, 0, hi-lo+1)
for v := lo; v <= hi; v++ {
out = append(out, v)
}
return out
}
func toSet(values []int) map[int]bool {
m := make(map[int]bool, len(values))
for _, v := range values {
m[v] = true
}
return m
}
// parseIntStrict parses a non-negative integer with no surrounding non-digits,
// mirroring the TS /^\d+$/ check. Returns an error shaped like the TS throw.
func parseIntStrict(s, label string) (int, error) {
t := strings.TrimSpace(s)
if t == "" {
return 0, fmt.Errorf("%s: invalid number %q", label, s)
}
for _, r := range t {
if r < '0' || r > '9' {
return 0, fmt.Errorf("%s: invalid number %q", label, s)
}
}
n, err := strconv.Atoi(t)
if err != nil {
return 0, fmt.Errorf("%s: invalid number %q", label, s)
}
return n, nil
}
// normalize uppercases the value and replaces named tokens (JAN..DEC / SUN..SAT)
// with their numeric values for named fields. The token replacements are
// independent (all tokens are 3 ASCII letters, all replacements are digits), so
// map iteration order does not affect the result.
func normalize(value string, meta fieldMeta) string {
v := strings.ToUpper(strings.TrimSpace(value))
if !meta.named {
return v
}
tokens := monthTokens
if meta.name != FieldMonth {
tokens = dowTokens
}
for tok, num := range tokens {
v = strings.ReplaceAll(v, tok, strconv.Itoa(num))
}
return v
}
// expandField expands one field value into the explicit set of numbers it
// matches. It mirrors expandField() in the TS lib, including the wrapMax rule
// (dow 7 → 0) and the "A/step" runs-to-max semantics.
func expandField(value string, meta fieldMeta) ([]int, bool, error) {
norm := normalize(value, meta)
if norm == "" {
return nil, false, fmt.Errorf("%s: empty field", meta.label)
}
if norm == "*" {
return makeRange(meta.min, meta.max), true, nil
}
set := map[int]bool{}
for _, term := range strings.Split(norm, ",") {
if term == "" {
return nil, false, fmt.Errorf("%s: empty list item", meta.label)
}
slashIdx := strings.Index(term, "/")
base := term
step := 1
if slashIdx != -1 {
base = term[:slashIdx]
s, err := parseIntStrict(term[slashIdx+1:], meta.label)
if err != nil {
return nil, false, err
}
step = s
if step <= 0 {
return nil, false, fmt.Errorf("%s: step must be a positive number", meta.label)
}
}
var lo, hi int
var err error
switch {
case base == "*":
lo = meta.min
hi = meta.max
case strings.Contains(base, "-"):
dashIdx := strings.Index(base, "-")
lo, err = parseIntStrict(base[:dashIdx], meta.label)
if err != nil {
return nil, false, err
}
hi, err = parseIntStrict(base[dashIdx+1:], meta.label)
if err != nil {
return nil, false, err
}
default:
lo, err = parseIntStrict(base, meta.label)
if err != nil {
return nil, false, err
}
// "A/step" runs from A to the field max; a bare "A" is a single value.
if slashIdx != -1 {
hi = meta.max
} else {
hi = lo
}
}
if lo > hi {
return nil, false, fmt.Errorf("%s: range start %d is greater than end %d", meta.label, lo, hi)
}
if lo < meta.min {
return nil, false, fmt.Errorf("%s: value %d is below minimum %d", meta.label, lo, meta.min)
}
if hi > meta.max {
return nil, false, fmt.Errorf("%s: value %d is above maximum %d", meta.label, hi, meta.max)
}
for v := lo; v <= hi; v += step {
if meta.wrapMax && v == meta.max {
set[meta.min] = true
} else {
set[v] = true
}
}
}
out := make([]int, 0, len(set))
for v := range set {
out = append(out, v)
}
sort.Ints(out)
return out, false, nil
}
type parsedField struct {
meta fieldMeta
raw string
values []int
wildcard bool
}
// parseExpr splits an expression into 5 parsed fields or returns an error. The
// per-field error message bubbles up verbatim, matching the TS catch behavior.
func parseExpr(expr string) ([]parsedField, error) {
tokens := strings.Fields(strings.TrimSpace(expr))
if len(tokens) != 5 {
return nil, fmt.Errorf("Expected 5 fields (minute hour day-of-month month day-of-week), got %d", len(tokens))
}
parts := make([]parsedField, 5)
for i := 0; i < 5; i++ {
values, wildcard, err := expandField(tokens[i], fields[i])
if err != nil {
return nil, err
}
parts[i] = parsedField{meta: fields[i], raw: tokens[i], values: values, wildcard: wildcard}
}
return parts, nil
}
func isContiguous(values []int) bool {
for i := 1; i < len(values); i++ {
if values[i]-values[i-1] != 1 {
return false
}
}
return true
}
// singleValue describes a single value in the field's vocabulary.
func singleValue(n int, meta fieldMeta) string {
switch meta.name {
case FieldMinute:
return fmt.Sprintf("minute %d", n)
case FieldHour:
return fmt.Sprintf("hour %d", n)
case FieldDayOfMonth:
return fmt.Sprintf("day %d of the month", n)
case FieldMonth:
return monthName(n)
case FieldDayOfWeek:
return dowName(n)
}
return ""
}
// describeField describes a parsed field as a human phrase (no leading
// preposition). raw is consulted to distinguish step syntax (star/N or A-B/N)
// from plain lists/ranges.
func describeField(p parsedField) string {
meta, raw, values := p.meta, p.raw, p.values
if p.wildcard {
switch meta.name {
case FieldMinute:
return "every minute"
case FieldHour:
return "every hour"
case FieldDayOfMonth:
return "every day of the month"
case FieldMonth:
return "every month"
case FieldDayOfWeek:
return "every day of the week"
}
}
// Step syntax: report as "every N …".
if strings.Contains(raw, "/") && len(values) >= 1 {
step, _ := parseIntStrict(raw[strings.Index(raw, "/")+1:], meta.label)
start := values[0]
var unitPlural string
switch meta.name {
case FieldDayOfMonth:
unitPlural = "days of the month"
case FieldDayOfWeek:
unitPlural = "days of the week"
default:
unitPlural = fmt.Sprintf("%ss", meta.name)
}
if start == meta.min {
return fmt.Sprintf("every %d %s", step, unitPlural)
}
return fmt.Sprintf("every %d %s starting at %s", step, unitPlural, singleValue(start, meta))
}
if len(values) == 1 {
return singleValue(values[0], meta)
}
if isContiguous(values) {
a, b := values[0], values[len(values)-1]
if meta.name == FieldMonth {
return fmt.Sprintf("%s through %s", monthName(a), monthName(b))
}
if meta.name == FieldDayOfWeek {
return fmt.Sprintf("%s through %s", dowName(a), dowName(b))
}
unitPlural := "days"
if meta.name != FieldDayOfMonth {
unitPlural = fmt.Sprintf("%ss", meta.name)
}
return fmt.Sprintf("%s %d through %d", unitPlural, a, b)
}
// Explicit list.
switch meta.name {
case FieldMonth:
return joinMonthNames(values)
case FieldDayOfWeek:
return joinDowNames(values)
case FieldMinute:
return "minutes " + joinInts(values)
case FieldHour:
return "hours " + joinInts(values)
default:
return "days " + joinInts(values) + " of the month"
}
}
// prepend prefixes a phrase, but never before one that already starts with "every".
func prepend(prefix, phrase string) string {
if strings.HasPrefix(phrase, "every") {
return phrase
}
return prefix + " " + phrase
}
func timeClause(minute, hour parsedField) string {
mAll := minute.wildcard
hAll := hour.wildcard
mSingle := !mAll && len(minute.values) == 1
hSingle := !hAll && len(hour.values) == 1
switch {
case mAll && hAll:
return "Every minute"
case mAll && hSingle:
return fmt.Sprintf("Every minute of hour %d", hour.values[0])
case mSingle && hAll:
return fmt.Sprintf("At minute %d of every hour", minute.values[0])
case mSingle && hSingle:
return fmt.Sprintf("At %s:%s", pad2(hour.values[0]), pad2(minute.values[0]))
}
// Mixed: describe each non-wildcard field, hour first.
var clauses []string
if !hAll {
clauses = append(clauses, describeField(hour))
}
if !mAll {
clauses = append(clauses, describeField(minute))
}
s := strings.Join(clauses, ", ")
return strings.ToUpper(s[:1]) + s[1:]
}
func composeDescription(parts []parsedField) string {
minute, hour, dom, month, dow := parts[0], parts[1], parts[2], parts[3], parts[4]
clauses := []string{timeClause(minute, hour)}
if !dom.wildcard {
clauses = append(clauses, prepend("on", describeField(dom)))
}
if !month.wildcard {
clauses = append(clauses, prepend("in", describeField(month)))
}
if !dow.wildcard {
clauses = append(clauses, prepend("on", describeField(dow)))
}
return strings.Join(clauses, ", ")
}
// ExplainCron parses and explains a 5-field cron expression in plain English.
// It is the Go twin of explainCron() in src/lib/cron-explainer.ts.
func ExplainCron(expr string) CronExplanation {
parts, err := parseExpr(expr)
if err != nil {
return CronExplanation{Valid: false, Description: "", Fields: nil, Error: err.Error()}
}
fieldInfos := make([]CronFieldInfo, len(parts))
for i, p := range parts {
fieldInfos[i] = CronFieldInfo{
Field: p.meta.name,
Value: p.raw,
Meaning: describeField(p),
}
}
return CronExplanation{
Valid: true,
Description: composeDescription(parts),
Fields: fieldInfos,
}
}
// BuildCron assembles a 5-field cron expression from user-friendly per-field
// specs. Each field defaults to "*" when empty; an invalid field returns an
// error. It is the Go twin of buildCron() in src/lib/cron-explainer.ts.
func BuildCron(opts BuildCronOptions) (string, error) {
specs := []struct {
meta fieldMeta
value string
}{
{fields[0], opts.Minute},
{fields[1], opts.Hour},
{fields[2], opts.Dom},
{fields[3], opts.Month},
{fields[4], opts.Dow},
}
out := make([]string, 0, 5)
for _, s := range specs {
v := strings.TrimSpace(s.value)
if v == "" {
out = append(out, "*")
continue
}
if _, _, err := expandField(v, s.meta); err != nil {
return "", err
}
out = append(out, v)
}
return strings.Join(out, " "), nil
}
// NextRun returns the next time the expression fires, strictly after `after`,
// evaluated in UTC. The bool is false when the expression is invalid or no
// firing occurs within ~3 years. It implements standard Vixie-cron day
// matching: when BOTH day-of-month and day-of-week are restricted, a match on
// either suffices; otherwise both must match. It is the Go twin of nextRun().
func NextRun(expr string, after time.Time) (time.Time, bool) {
parts, err := parseExpr(expr)
if err != nil {
return time.Time{}, false
}
minute, hour, dom, month, dow := parts[0], parts[1], parts[2], parts[3], parts[4]
mSet := toSet(minute.values)
hSet := toSet(hour.values)
domSet := toSet(dom.values)
monSet := toSet(month.values)
dowSet := toSet(dow.values)
domWild := dom.wildcard
dowWild := dow.wildcard
// Start at the top of the minute following `after` (UTC).
a := after.UTC()
cur := time.Date(a.Year(), a.Month(), a.Day(), a.Hour(), a.Minute(), 0, 0, time.UTC).Add(time.Minute)
limit := cur.Year() + 3 // hard stop ~3 years out
for cur.Year() < limit {
if !monSet[int(cur.Month())] {
cur = time.Date(cur.Year(), cur.Month()+1, 1, 0, 0, 0, 0, time.UTC)
continue
}
domOk := domSet[cur.Day()]
dowOk := dowSet[int(cur.Weekday())]
dayOk := domOk && dowOk
if !domWild && !dowWild {
dayOk = domOk || dowOk
}
if !dayOk {
cur = time.Date(cur.Year(), cur.Month(), cur.Day()+1, 0, 0, 0, 0, time.UTC)
continue
}
if !hSet[cur.Hour()] {
cur = time.Date(cur.Year(), cur.Month(), cur.Day(), cur.Hour()+1, 0, 0, 0, time.UTC)
continue
}
if !mSet[cur.Minute()] {
cur = time.Date(cur.Year(), cur.Month(), cur.Day(), cur.Hour(), cur.Minute()+1, 0, 0, time.UTC)
continue
}
return cur, true
}
return time.Time{}, 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 →