cron_parser
forked from kostya/cron_parserCronParser
Cron parser for the Crystal language. Translated from Ruby https://github.com/siebertm/parse-cron. It parses a crontab timing specification and determines when the job should be run. It is not a scheduler: it does not run the jobs.
This is a maintained fork of kostya/cron_parser, which has been unchanged since 2021. It fixes the crashes and the silently wrong schedules described under Validation and Time zones. Three behaviours differ from upstream, all of them because upstream disagreed with a system cron: a bound outside its field's range is rejected rather than clamped, a field beginning with * counts as a wildcard for the day-of-month / day-of-week rule, and an occurrence whose wall clock a time zone skipped is placed where the clock jumped to.
Installation
Add this to your application's shard.yml:
dependencies:
cron_parser:
github: n-rodriguez/cron_parser
Usage
require "cron_parser"
cron_parser = CronParser.new("30 * * * *")
# Upcoming times
p cron_parser.next(Time.local)
p cron_parser.next(Time.local, 5)
p cron_parser.next(Time.utc)
p cron_parser.next(Time.utc, 5)
# Times that have been
p cron_parser.last(Time.local)
p cron_parser.last(Time.local, 5)
The returned times carry the location of the time you passed in, so a parser can be driven with local or UTC times interchangeably — see Time zones and DST.
Fields
The standard five fields are supported, with *, n, n-m, n-m/s, */s and comma-separated lists, plus the @yearly, @annually, @monthly, @weekly, @daily, @midnight and @hourly shorthands. @reboot is rejected: its next run cannot be predicted.
| Field | Range | Names |
|---|---|---|
| minute | 0-59 | |
| hour | 0-23 | |
| day of month | 1-31 | |
| month | 1-12 | jan … dec |
| day of week | 0-7 | sun … sat (0 and 7 are both Sunday) |
Names are only recognised in their own field: 0 0 1 mon * is rejected rather than read as January.
Day of month and day of week
When day-of-month and day-of-week are both restricted, a day matches if it satisfies either — standard cron behaviour, not an intersection:
CronParser.new("0 0 13 * 5") # the 13th of the month, or any Friday
A field counts as unrestricted when it begins with *, which is what Vixie cron tests (entry.c, if (ch == '*') e->flags |= DOM_STAR;) before it parses the list. So a stepped star such as */2 leaves the field open and the other one alone decides:
CronParser.new("0 0 */2 * 5") # Fridays only, not "every other day or a Friday"
CronParser.new("0 0 15 * */1") # the 15th only
Upstream compared the whole field against "*" instead, which made */2 restricted and quietly widened those two lines to a union.
Seconds (extension)
A sixth field may be given, in which case the leading field is seconds and the remaining five keep their usual meaning:
CronParser.new("*/15 30 * * * *") # at second 0, 15, 30 and 45 of minute 30
Validation
Every field is checked when the parser is built. A value outside its range, a reversed range (22-2), a malformed step (*/0) and a name in the wrong field all raise ArgumentError from the constructor, naming the offending element:
CronParser.new("0 20-24 * * *")
# ArgumentError: 24 is out of range 0..23 in "20-24" ("0 20-24 * * *")
A day/month pair that can never match — February 30th, for instance — is rejected the same way. Ranges do not wrap around, so fri-sun is invalid; write 5-7 instead.
Two bounds guard the walk itself and raise ArgumentError from #next / #last rather than from the constructor. Neither is reachable with a cronline the constructor accepted; they exist so that a weakened check surfaces as an error instead of a hang.
Time zones and DST
The returned time carries the location of the time you passed in, and the walk is done in that location's wall clock — the same thing a system cron does. Nothing reads the process TZ, so two parsers driven with two different locations do not interfere.
#next always returns a time strictly after its argument, and #last one strictly before, in every zone. That guarantee is not free: the walk orders candidates as wall clocks, and a wall clock has to be turned back into an instant. On the day a zone moves, that instant can land on the wrong side of the input — the wall clock either never happened or happened twice. Upstream returned it as-is, so on a zone that switches at midnight #next could return the input itself:
# upstream, America/Havana — 2001-04-01 00:00 never existed
havana = Time::Location.load("America/Havana")
CronParser.new("0 0 * * *").next(Time.local(2001, 3, 31, 23, 0, 0, location: havana))
# upstream => 2001-03-31 23:00:00 -05:00, the argument unchanged
# here => 2001-04-01 01:00:00 -04:00, when the clock reached 01:00
A scheduler looping on that result re-fires its job without end. Here the walk is stepped until the instant clears the input, so the sequence is monotonic whatever the zone does. Measured over 15 zones and 7 cronlines, a full year at 37-minute strides in both directions: 2 991 450 results, none out of order.
An occurrence whose wall clock the zone skipped is placed at the instant the gap ends — where Vixie runs it, its scheduler walking virtual time through every minute the jump swallowed and running their fixed-time jobs as soon as the clock has moved. Time.local on its own does not do that: it normalises an impossible wall clock forward in some zones and backward in others, neither of which is a time a system cron would have fired at.
paris = Time::Location.load("Europe/Paris")
havana = Time::Location.load("America/Havana")
# Europe/Paris springs forward 02:00 -> 03:00, so a 02:30 job runs at 03:00
CronParser.new("30 2 * * *").next(Time.local(2024, 3, 30, 12, 0, 0, location: paris))
# => 2024-03-31 03:00:00 +02:00 (Time.local alone would say 03:30)
# America/Havana springs forward 00:00 -> 01:00, so a midnight job runs at 01:00
CronParser.new("0 0 * * *").next(Time.local(2024, 3, 9, 12, 0, 0, location: havana))
# => 2024-03-10 01:00:00 -04:00 (Time.local alone would say 23:00 the day before)
Every wall clock inside one gap resolves to that same instant, and the strict ordering then drops the duplicates: a quarter-hourly job fires once at the jump, not once per skipped quarter.
An occurrence is lost only when the calendar day itself is gone, as on the date Pacific/Apia deleted when it crossed the date line:
apia = Time::Location.load("Pacific/Apia")
CronParser.new("0 0 * * *").next(Time.local(2011, 12, 29, 12, 0, 0, location: apia))
# => 2011-12-31 00:00:00 +14:00 — there was no 30th
An occurrence inside a repeated hour fires once, on the second pass:
CronParser.new("30 2 * * *").next(Time.local(2024, 10, 26, 12, 0, 0, location: paris))
# => 2024-10-27 02:30:00 +01:00 — one result, at 01:30 UTC, not two
The whole check is skipped for a location that never changes offset, so driving the parser with UTC times costs nothing: measured at 97 ns per #next against 96 ns before this behaviour existed, where a zone with transitions goes from 110 to 129 ns.
Thread safety
A parser instance is not safe to share across threads: #next and #last memoise per-month day lists in an unsynchronised Hash. Build one parser per thread, or guard your own access. Sharing across fibers of a single thread is safe, as neither method yields.
Development
Tasks are driven by mise, which also pins the compiler (Crystal 1.20.3) — the CI runs these same tasks:
mise dev:deps # shards install, and drops bin/ameba in place
mise dev:spec # run the suite
mise dev:spec-mt # run it multi-threaded
mise dev:format-check # formatting
mise dev:ameba # static analysis
mise dev:docs # crystal doc
crystal spec works on a bare checkout too: the shard has no runtime dependencies. ameba is the only development dependency, and is deliberately left unpinned in shard.yml so that shards install gets a release declaring executables: and installs the binary itself.
cron_parser
- 0
- 0
- 0
- 3
- 1
- about 4 hours ago
- August 8, 2026
MIT License
Sun, 09 Aug 2026 14:55:53 GMT