Skip to content

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 →