Cron Expression Explainer — Ruby 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 Ruby 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 — Ruby polyglot showcase port.
#
# Language: Ruby (3.2+, 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: explain_cron,
# build_cron, next_run.
require 'set'
require 'time'
module 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). wrap_max is true
# only for day-of-week, where 7 is treated as an alias for 0 (Sunday).
FieldMeta = Data.define(:name, :min, :max, :named, :wrap_max)
FIELDS = [
FieldMeta.new(name: 'minute', min: 0, max: 59, named: false, wrap_max: false),
FieldMeta.new(name: 'hour', min: 0, max: 23, named: false, wrap_max: false),
FieldMeta.new(name: 'day-of-month', min: 1, max: 31, named: false, wrap_max: false),
FieldMeta.new(name: 'month', min: 1, max: 12, named: true, wrap_max: false),
FieldMeta.new(name: 'day-of-week', min: 0, max: 7, named: true, wrap_max: true)
].freeze
MONTH_NAMES = %w[
January February March April May June
July August September October November December
].freeze
DOW_NAMES = %w[Sunday Monday Tuesday Wednesday Thursday Friday Saturday].freeze
MONTH_TOKENS = {
'JAN' => 1, 'FEB' => 2, 'MAR' => 3, 'APR' => 4, 'MAY' => 5, 'JUN' => 6,
'JUL' => 7, 'AUG' => 8, 'SEP' => 9, 'OCT' => 10, 'NOV' => 11, 'DEC' => 12
}.freeze
DOW_TOKENS = {
'SUN' => 0, 'MON' => 1, 'TUE' => 2, 'WED' => 3, 'THU' => 4, 'FRI' => 5, 'SAT' => 6
}.freeze
# Raised for a malformed single field. Carries a human-readable message
# (mirrors the Python port's CronError, a ValueError subclass).
class CronError < StandardError; end
ParsedField = Data.define(:meta, :raw, :values, :wildcard)
class << self
def pad2(n)
format('%02d', n)
end
def month_name(m)
MONTH_NAMES[m - 1]
end
def dow_name(d)
DOW_NAMES[d % 7]
end
# Inclusive integer range, e.g. inclusive_range(1, 5) -> [1, 2, 3, 4, 5].
def inclusive_range(lo, hi)
(lo..hi).to_a
end
# Parse a strictly-numeric token (digits only). Rejects named tokens,
# signs, and surrounding garbage so malformed fields surface clearly.
def parse_int_strict(s, label)
t = s.strip
raise CronError, "#{label}: invalid number \"#{s}\"" unless t.match?(/\A\d+\z/)
t.to_i
end
# 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.
def normalize(value, meta)
v = value.strip.upcase
return v unless meta.named
tokens = meta.name == 'month' ? MONTH_TOKENS : DOW_TOKENS
tokens.each { |tok, num| v = v.gsub(tok, num.to_s) }
v
end
# 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 distinguishing a bare '*'.
def expand_field(value, meta)
norm = normalize(value, meta)
raise CronError, "#{meta.name}: empty field" if norm.empty?
return ParsedField.new(meta: meta, raw: value, values: inclusive_range(meta.min, meta.max),
wildcard: true) if norm == '*'
out = []
norm.split(',').each do |term|
raise CronError, "#{meta.name}: empty list item" if term.empty?
slash_idx = term.index('/')
base = term
step = 1
if slash_idx
base = term[0...slash_idx]
step = parse_int_strict(term[(slash_idx + 1)..], meta.name)
raise CronError, "#{meta.name}: step must be a positive number" if step <= 0
end
if base == '*'
lo = meta.min
hi = meta.max
elsif (dash = base.index('-'))
lo = parse_int_strict(base[0...dash], meta.name)
hi = parse_int_strict(base[(dash + 1)..], meta.name)
else
lo = parse_int_strict(base, meta.name)
# 'A/step' runs from A to the field max; a bare 'A' is a single value.
hi = slash_idx ? meta.max : lo
end
raise CronError, "#{meta.name}: range start #{lo} is greater than end #{hi}" if lo > hi
raise CronError, "#{meta.name}: value #{lo} is below minimum #{meta.min}" if lo < meta.min
raise CronError, "#{meta.name}: value #{hi} is above maximum #{meta.max}" if hi > meta.max
v = lo
while v <= hi
out << (meta.wrap_max && v == meta.max ? meta.min : v)
v += step
end
end
ParsedField.new(meta: meta, raw: value, values: out.uniq.sort, wildcard: false)
end
# Parse all five fields. Returns [parts, nil] or [nil, error_message].
def parse_expr(expr)
tokens = expr.strip.split
if tokens.length != 5
return nil, "Expected 5 fields (minute hour day-of-month month day-of-week), got #{tokens.length}"
end
parts = tokens.each_index.map do |i|
expand_field(tokens[i], FIELDS[i])
rescue CronError => e
return nil, e.message
end
[parts, nil]
end
# True when a sorted value list is a contiguous run (e.g. [3, 4, 5, 6]).
def contiguous?(values)
values.each_cons(2).all? { |a, b| b - a == 1 }
end
# Describe a single value in the field's own vocabulary.
def single_value(n, meta)
case meta.name
when 'minute' then "minute #{n}"
when 'hour' then "hour #{n}"
when 'day-of-month' then "day #{n} of the month"
when 'month' then month_name(n)
else dow_name(n) # day-of-week
end
end
# Describe 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, since two different raw forms can expand to the same set.
def describe_field(p)
meta = p.meta
values = p.values
return {
'minute' => 'every minute',
'hour' => 'every hour',
'day-of-month' => 'every day of the month',
'month' => 'every month',
'day-of-week' => 'every day of the week'
}[meta.name] if p.wildcard
# Step syntax is reported as 'every N <units>'.
if p.raw.include?('/') && !values.empty?
step = begin
parse_int_strict(p.raw[(p.raw.index('/') + 1)..], meta.name)
rescue CronError
1 # multi-term raw ('*/5,10-20/3') — fall back like the Rust port
end
start = values.first
unit_plural =
case meta.name
when 'day-of-month' then 'days of the month'
when 'day-of-week' then 'days of the week'
else "#{meta.name}s"
end
return "every #{step} #{unit_plural}" if start == meta.min
return "every #{step} #{unit_plural} starting at #{single_value(start, meta)}"
end
return single_value(values.first, meta) if values.length == 1
if contiguous?(values)
a = values.first
b = values.last
return "#{month_name(a)} through #{month_name(b)}" if meta.name == 'month'
return "#{dow_name(a)} through #{dow_name(b)}" if meta.name == 'day-of-week'
unit_plural = meta.name == 'day-of-month' ? 'days' : "#{meta.name}s"
return "#{unit_plural} #{a} through #{b}"
end
# Explicit list of discrete values.
return values.map { |v| month_name(v) }.join(', ') if meta.name == 'month'
return values.map { |v| dow_name(v) }.join(', ') if meta.name == 'day-of-week'
joined = values.join(', ')
return "minutes #{joined}" if meta.name == 'minute'
return "hours #{joined}" if meta.name == 'hour'
"days #{joined} of the month"
end
# 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 ...').
def prepend(prefix, phrase)
phrase.start_with?('every') ? phrase : "#{prefix} #{phrase}"
end
# Compose the opening time-of-day clause from minute and hour fields.
def time_clause(minute, hour)
m_all = minute.wildcard
h_all = hour.wildcard
m_single = !m_all && minute.values.length == 1
h_single = !h_all && hour.values.length == 1
return 'Every minute' if m_all && h_all
return "Every minute of hour #{hour.values.first}" if m_all && h_single
return "At minute #{minute.values.first} of every hour" if m_single && h_all
return "At #{pad2(hour.values.first)}:#{pad2(minute.values.first)}" if m_single && h_single
# Mixed: describe each non-wildcard field, hour first.
clauses = []
clauses << describe_field(hour) unless h_all
clauses << describe_field(minute) unless m_all
s = clauses.join(', ')
s[0].upcase + s[1..]
end
def compose_description(parts)
minute, hour, dom, month, dow = parts
clauses = [time_clause(minute, hour)]
clauses << prepend('on', describe_field(dom)) unless dom.wildcard
clauses << prepend('in', describe_field(month)) unless month.wildcard
clauses << prepend('on', describe_field(dow)) unless dow.wildcard
clauses.join(', ')
end
end
# ─── Public API ───────────────────────────────────────────────────────────
CronFieldInfo = Data.define(:field, :value, :meaning)
CronExplanation = Data.define(:valid, :description, :fields, :error)
class << self
# Parse and explain a 5-field cron expression in plain English.
#
# CronExplainer.explain_cron('30 14 * * *').description # => 'At 14:30'
def explain_cron(expr)
parts, error = parse_expr(expr)
return CronExplanation.new(valid: false, description: '', fields: [], error: error) if error
fields = parts.map do |p|
CronFieldInfo.new(field: p.meta.name, value: p.raw, meaning: describe_field(p))
end
CronExplanation.new(valid: true, description: compose_description(parts),
fields: fields, error: nil)
end
# Assemble a 5-field cron expression from per-field specs. Each field
# defaults to '*' when empty/omitted; invalid fields raise CronError so
# callers cannot build a malformed expression.
#
# CronExplainer.build_cron(minute: '30', hour: '14') # => '30 14 * * *'
def build_cron(**opts)
specs = [
FIELDS[0], opts[:minute],
FIELDS[1], opts[:hour],
FIELDS[2], opts[:dom],
FIELDS[3], opts[:month],
FIELDS[4], opts[:dow]
].each_slice(2).to_a
specs.map do |meta, value|
v = value.to_s.strip
next '*' if v.empty?
expand_field(v, meta) # validates; raises on bad input
v
end.join(' ')
end
# Next time the expression fires, strictly after `after`, 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 nil if no firing occurs within ~3 years.
def next_run(expr, after)
parts, error = parse_expr(expr)
return nil if error
minute, hour, dom, month, dow = parts
m_set = minute.values.to_set
h_set = hour.values.to_set
dom_set = dom.values.to_set
mon_set = month.values.to_set
dow_set = dow.values.to_set
dom_wild = dom.wildcard
dow_wild = dow.wildcard
# Work in UTC; start at the top of the minute following `after`.
cur = after.utc
cur = Time.utc(cur.year, cur.month, cur.day, cur.hour, cur.min) + 60
limit = cur.year + 3 # hard stop ~3 years out
while cur.year < limit
unless mon_set.include?(cur.month)
# Advance to day 1 of next month, midnight.
cur = if cur.month == 12
Time.utc(cur.year + 1, 1, 1)
else
Time.utc(cur.year, cur.month + 1, 1)
end
next
end
dom_ok = dom_set.include?(cur.day)
# Ruby Time#wday is already 0=Sunday..6=Saturday — cron's convention.
dow_ok = dow_set.include?(cur.wday)
day_ok = dom_wild || dow_wild ? dom_ok && dow_ok : dom_ok || dow_ok
unless day_ok
cur = Time.utc(cur.year, cur.month, cur.day) + 86_400
next
end
unless h_set.include?(cur.hour)
cur = Time.utc(cur.year, cur.month, cur.day, cur.hour) + 3_600
next
end
unless m_set.include?(cur.min)
cur += 60
next
end
return cur
end
nil
end
end
end
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 →