ruby 73 lines · 10 steps

Parsing and matching cron expressions in Ruby

A CronSchedule turns a five-field cron string into value sets and finds the next matching minute by stepping time forward.

Explained by highlit
1require 'set'
2 
3class CronSchedule
4 FIELDS = %i[minute hour day month wday].freeze
5 RANGES = {
6 minute: 0..59,
7 hour: 0..23,
8 day: 1..31,
9 month: 1..12,
10 wday: 0..6
11 }.freeze
12 
13 def initialize(expression)
14 parts = expression.split(/\s+/)
15 raise ArgumentError, "expected 5 fields, got #{parts.size}" unless parts.size == 5
16 @sets = FIELDS.zip(parts).to_h { |field, spec| [field, parse_field(spec, RANGES[field])] }
17 end
18 
19 def next_run(from = Time.now)
20 time = (from + 60).round
21 time -= time.sec
22 
23 1_000_000.times do
24 unless @sets[:month].include?(time.month)
25 time = advance_month(time)
26 next
27 end
28 unless day_matches?(time)
29 time += 86_400
30 time -= time.hour * 3600 + time.min * 60
31 next
32 end
33 unless @sets[:hour].include?(time.hour)
34 time += 3600 - time.min * 60
35 next
36 end
37 return time if @sets[:minute].include?(time.min)
38 time += 60
39 end
40 raise "no matching time found within search window"
41 end
42 
43 private
44 
45 def day_matches?(time)
46 @sets[:day].include?(time.mday) && @sets[:wday].include?(time.wday)
47 end
48 
49 def advance_month(time)
50 month = time.month == 12 ? 1 : time.month + 1
51 year = time.month == 12 ? time.year + 1 : time.year
52 Time.new(year, month, 1, 0, 0, 0, time.utc_offset)
53 end
54 
55 def parse_field(spec, range)
56 spec.split(',').flat_map { |token| parse_token(token, range) }.to_set
57 end
58 
59 def parse_token(token, range)
60 base, step = token.split('/')
61 step = (step || 1).to_i
62 values =
63 if base == '*'
64 range.to_a
65 elsif base.include?('-')
66 lo, hi = base.split('-').map(&:to_i)
67 (lo..hi).to_a
68 else
69 [base.to_i]
70 end
71 values.each_slice(step).map(&:first)
72 end
73end
01 / 01
STEP 01

Walkthrough

Space play step click any line
Three takeaways
  1. 1Precomputing each cron field into a Set makes the matching loop a series of cheap membership checks.
  2. 2Skipping ahead by the coarsest failing unit converges far faster than checking every minute one at a time.
  3. 3A bounded iteration count guards against infinite loops when an expression can never match.

Related explainers

Share this explainer

Here's the card — post it anywhere.

Parsing and matching cron expressions in Ruby — share card
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code