Skip to content

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 →