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
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1Precomputing each cron field into a Set makes the matching loop a series of cheap membership checks.
- 2Skipping ahead by the coarsest failing unit converges far faster than checking every minute one at a time.
- 3A bounded iteration count guards against infinite loops when an expression can never match.
Related explainers
ruby
require "shellwords" require "open3" module Backup
Building safe shell commands in Ruby
shell-out
subprocess
command-injection
Intermediate
7 steps
ruby
class UserAgentParser BROWSERS = [ [/Edg\/([\d.]+)/, "Edge"], [/OPR\/([\d.]+)/, "Opera"],
Parsing user-agent strings in Ruby
regex
pattern-matching
lookup-tables
Intermediate
8 steps
javascript
function evaluate(expression) { const tokens = tokenize(expression); let pos = 0;
Building a recursive descent calculator
parsing
recursion
operator-precedence
Intermediate
8 steps
ruby
class LogAggregator BUCKET_FORMAT = "%Y-%m-%dT%H:%M" def initialize(entries)
Bucketing log entries by the minute in Ruby
aggregation
hashing
enumerable
Intermediate
5 steps
ruby
class WeeklySignupsReport DEFAULT_WEEKS = 12 def initialize(weeks: DEFAULT_WEEKS, source: User.all)
Building a weekly signups report in Rails
service object
aggregation
group by
Intermediate
7 steps
rust
use chrono::{Duration, NaiveDate}; #[derive(Debug)] pub struct DateRange {
Parsing and iterating date ranges in Rust
error-handling
iterators
parsing
Intermediate
7 steps
Share this explainer
Here's the card — post it anywhere.
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code
Embed this explainer
Drop the interactive walkthrough into a blog or docs. Views never cost a credit.
<iframe src="https://highlit.co/explainers/parsing-and-matching-cron-expressions-in-ruby-explained-ruby-ef8a/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.