├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── bench ├── .gitignore ├── bench.cr ├── bench.rb └── cron_parser.rb ├── example.cr ├── shard.yml ├── spec ├── cron_parser_spec.cr └── spec_helper.cr └── src └── cron_parser.cr /.gitignore: -------------------------------------------------------------------------------- 1 | /doc/ 2 | /libs/ 3 | .crystal 4 | /.shards/ 5 | 6 | 7 | # Libraries don't need dependency lock 8 | # Dependencies will be locked in application that uses them 9 | /shard.lock 10 | 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: crystal 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 'Konstantin Makarchev' 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CronParser 2 | 3 | Cron parser for Crystal language. Translated from Ruby https://github.com/siebertm/parse-cron. It is parse a crontab timing specification and determine when the job should be run. It is not a scheduler, it does not run the jobs. 4 | 5 | ## Installation 6 | 7 | 8 | Add this to your application's `shard.yml`: 9 | 10 | ```yaml 11 | dependencies: 12 | cron_parser: 13 | github: kostya/cron_parser 14 | ``` 15 | 16 | 17 | ## Usage 18 | 19 | 20 | ```crystal 21 | require "cron_parser" 22 | 23 | cron_parser = CronParser.new("30 * * * *") 24 | 25 | # Comming times 26 | p cron_parser.next(Time.now) 27 | p cron_parser.next(Time.now, 5) 28 | 29 | p cron_parser.next(Time.utc_now) 30 | p cron_parser.next(Time.utc_now, 5) 31 | 32 | # Times that have been 33 | p cron_parser.last(Time.now) 34 | p cron_parser.last(Time.now, 5) 35 | ``` 36 | 37 | -------------------------------------------------------------------------------- /bench/.gitignore: -------------------------------------------------------------------------------- 1 | bench 2 | -------------------------------------------------------------------------------- /bench/bench.cr: -------------------------------------------------------------------------------- 1 | require "../src/cron_parser" 2 | 3 | N = (ARGV[0]? || 10).to_i 4 | t11 = Time.new 2015, 10, 12, 18, 33, 0 5 | 6 | t = Time.now 7 | s = 0 8 | (10 * N).times do |i| 9 | cron_parser = CronParser.new("#{i % 60} #{i % 24} * * *") 10 | 100.times do 11 | s += (cron_parser.next(t11) - t11).to_f / 100_000.0 12 | end 13 | end 14 | p s 15 | p (Time.now - t).to_f 16 | 17 | t = Time.now 18 | s = 0 19 | N.times do |i| 20 | cron_parser = CronParser.new("30 #{i % 24} * * *") 21 | 22 | # Comming times 23 | s += cron_parser.next(t11, 100 * 100).size 24 | s += cron_parser.last(t11, 100 * 100).size 25 | end 26 | 27 | p s 28 | p (Time.now - t).to_f 29 | -------------------------------------------------------------------------------- /bench/bench.rb: -------------------------------------------------------------------------------- 1 | require "#{File.dirname(__FILE__)}/cron_parser.rb" 2 | 3 | N = (ARGV[0] || 10).to_i 4 | t11 = Time.new 2015, 10, 12, 18, 33, 0 5 | 6 | t = Time.now 7 | s = 0 8 | (10 * N).times do |i| 9 | cron_parser = CronParser.new("#{i % 60} #{i % 24} * * *") 10 | 100.times do 11 | s += (cron_parser.next(t11) - t11).to_f / 100_000.0 12 | end 13 | end 14 | p s 15 | p (Time.now - t).to_f 16 | 17 | t = Time.now 18 | s = 0 19 | N.times do |i| 20 | cron_parser = CronParser.new("30 #{i % 24} * * *") 21 | 22 | # Comming times 23 | s += cron_parser.next(t11, 100 * 100).size 24 | s += cron_parser.last(t11, 100 * 100).size 25 | end 26 | 27 | p s 28 | p (Time.now - t).to_f 29 | -------------------------------------------------------------------------------- /bench/cron_parser.rb: -------------------------------------------------------------------------------- 1 | require 'set' 2 | require 'date' 3 | 4 | # Parses cron expressions and computes the next occurence of the "job" 5 | # 6 | class CronParser 7 | # internal "mutable" time representation 8 | class InternalTime 9 | attr_accessor :year, :month, :day, :hour, :min 10 | attr_accessor :time_source 11 | 12 | def initialize(time,time_source = Time) 13 | @year = time.year 14 | @month = time.month 15 | @day = time.day 16 | @hour = time.hour 17 | @min = time.min 18 | 19 | @time_source = time_source 20 | end 21 | 22 | def to_time 23 | time_source.local(@year, @month, @day, @hour, @min, 0) 24 | end 25 | 26 | def inspect 27 | [year, month, day, hour, min].inspect 28 | end 29 | end 30 | 31 | SYMBOLS = { 32 | "jan" => "1", 33 | "feb" => "2", 34 | "mar" => "3", 35 | "apr" => "4", 36 | "may" => "5", 37 | "jun" => "6", 38 | "jul" => "7", 39 | "aug" => "8", 40 | "sep" => "9", 41 | "oct" => "10", 42 | "nov" => "11", 43 | "dec" => "12", 44 | 45 | "sun" => "0", 46 | "mon" => "1", 47 | "tue" => "2", 48 | "wed" => "3", 49 | "thu" => "4", 50 | "fri" => "5", 51 | "sat" => "6" 52 | } 53 | 54 | def initialize(source,time_source = Time) 55 | @source = interpret_vixieisms(source) 56 | @time_source = time_source 57 | validate_source 58 | end 59 | 60 | def interpret_vixieisms(spec) 61 | case spec 62 | when '@reboot' 63 | raise ArgumentError, "Can't predict last/next run of @reboot" 64 | when '@yearly', '@annually' 65 | '0 0 1 1 *' 66 | when '@monthly' 67 | '0 0 1 * *' 68 | when '@weekly' 69 | '0 0 * * 0' 70 | when '@daily', '@midnight' 71 | '0 0 * * *' 72 | when '@hourly' 73 | '0 * * * *' 74 | else 75 | spec 76 | end 77 | end 78 | 79 | 80 | # returns the next occurence after the given date 81 | def next(now = @time_source.now, num = 1) 82 | t = InternalTime.new(now, @time_source) 83 | 84 | unless time_specs[:month][0].include?(t.month) 85 | nudge_month(t) 86 | t.day = 0 87 | end 88 | 89 | unless interpolate_weekdays(t.year, t.month)[0].include?(t.day) 90 | nudge_date(t) 91 | t.hour = -1 92 | end 93 | 94 | unless time_specs[:hour][0].include?(t.hour) 95 | nudge_hour(t) 96 | t.min = -1 97 | end 98 | 99 | # always nudge the minute 100 | nudge_minute(t) 101 | t = t.to_time 102 | if num > 1 103 | recursive_calculate(:next,t,num) 104 | else 105 | t 106 | end 107 | end 108 | 109 | # returns the last occurence before the given date 110 | def last(now = @time_source.now, num=1) 111 | t = InternalTime.new(now,@time_source) 112 | 113 | unless time_specs[:month][0].include?(t.month) 114 | nudge_month(t, :last) 115 | t.day = 32 116 | end 117 | 118 | if t.day == 32 || !interpolate_weekdays(t.year, t.month)[0].include?(t.day) 119 | nudge_date(t, :last) 120 | t.hour = 24 121 | end 122 | 123 | unless time_specs[:hour][0].include?(t.hour) 124 | nudge_hour(t, :last) 125 | t.min = 60 126 | end 127 | 128 | # always nudge the minute 129 | nudge_minute(t, :last) 130 | t = t.to_time 131 | if num > 1 132 | recursive_calculate(:last,t,num) 133 | else 134 | t 135 | end 136 | end 137 | 138 | 139 | SUBELEMENT_REGEX = %r{^(\d+)(-(\d+)(/(\d+))?)?$} 140 | def parse_element(elem, allowed_range) 141 | values = elem.split(',').map do |subel| 142 | if subel =~ /^\*/ 143 | step = subel.length > 1 ? subel[2..-1].to_i : 1 144 | stepped_range(allowed_range, step) 145 | else 146 | if SUBELEMENT_REGEX === subel 147 | if $5 # with range 148 | stepped_range($1.to_i..$3.to_i, $5.to_i) 149 | elsif $3 # range without step 150 | stepped_range($1.to_i..$3.to_i, 1) 151 | else # just a numeric 152 | [$1.to_i] 153 | end 154 | else 155 | raise ArgumentError, "Bad Vixie-style specification #{subel}" 156 | end 157 | end 158 | end.flatten.sort 159 | 160 | [Set.new(values), values, elem] 161 | end 162 | 163 | 164 | protected 165 | 166 | def recursive_calculate(meth,time,num) 167 | array = [time] 168 | num.-(1).times do |num| 169 | array << self.send(meth, array.last) 170 | end 171 | array 172 | end 173 | 174 | # returns a list of days which do both match time_spec[:dom] or time_spec[:dow] 175 | def interpolate_weekdays(year, month) 176 | @_interpolate_weekdays_cache ||= {} 177 | @_interpolate_weekdays_cache["#{year}-#{month}"] ||= interpolate_weekdays_without_cache(year, month) 178 | end 179 | 180 | def interpolate_weekdays_without_cache(year, month) 181 | t = Date.new(year, month, 1) 182 | valid_mday, _, mday_field = time_specs[:dom] 183 | valid_wday, _, wday_field = time_specs[:dow] 184 | 185 | # Careful, if both DOW and DOM fields are non-wildcard, 186 | # then we only need to match *one* for cron to run the job: 187 | if not (mday_field == '*' and wday_field == '*') 188 | valid_mday = [] if mday_field == '*' 189 | valid_wday = [] if wday_field == '*' 190 | end 191 | # Careful: crontabs may use either 0 or 7 for Sunday: 192 | valid_wday << 0 if valid_wday.include?(7) 193 | 194 | result = [] 195 | while t.month == month 196 | result << t.mday if valid_mday.include?(t.mday) || valid_wday.include?(t.wday) 197 | t = t.succ 198 | end 199 | 200 | [Set.new(result), result] 201 | end 202 | 203 | def nudge_year(t, dir = :next) 204 | t.year = t.year + (dir == :next ? 1 : -1) 205 | end 206 | 207 | def nudge_month(t, dir = :next) 208 | spec = time_specs[:month][1] 209 | next_value = find_best_next(t.month, spec, dir) 210 | t.month = next_value || (dir == :next ? spec.first : spec.last) 211 | 212 | nudge_year(t, dir) if next_value.nil? 213 | 214 | # we changed the month, so its likely that the date is incorrect now 215 | valid_days = interpolate_weekdays(t.year, t.month)[1] 216 | t.day = dir == :next ? valid_days.first : valid_days.last 217 | end 218 | 219 | def date_valid?(t, dir = :next) 220 | interpolate_weekdays(t.year, t.month)[0].include?(t.day) 221 | end 222 | 223 | def nudge_date(t, dir = :next, can_nudge_month = true) 224 | spec = interpolate_weekdays(t.year, t.month)[1] 225 | next_value = find_best_next(t.day, spec, dir) 226 | t.day = next_value || (dir == :next ? spec.first : spec.last) 227 | 228 | nudge_month(t, dir) if next_value.nil? && can_nudge_month 229 | end 230 | 231 | def nudge_hour(t, dir = :next) 232 | spec = time_specs[:hour][1] 233 | next_value = find_best_next(t.hour, spec, dir) 234 | t.hour = next_value || (dir == :next ? spec.first : spec.last) 235 | 236 | nudge_date(t, dir) if next_value.nil? 237 | end 238 | 239 | def nudge_minute(t, dir = :next) 240 | spec = time_specs[:minute][1] 241 | next_value = find_best_next(t.min, spec, dir) 242 | t.min = next_value || (dir == :next ? spec.first : spec.last) 243 | 244 | nudge_hour(t, dir) if next_value.nil? 245 | end 246 | 247 | def time_specs 248 | @time_specs ||= begin 249 | # tokens now contains the 5 fields 250 | tokens = substitute_parse_symbols(@source).split(/\s+/) 251 | { 252 | :minute => parse_element(tokens[0], 0..59), #minute 253 | :hour => parse_element(tokens[1], 0..23), #hour 254 | :dom => parse_element(tokens[2], 1..31), #DOM 255 | :month => parse_element(tokens[3], 1..12), #mon 256 | :dow => parse_element(tokens[4], 0..6) #DOW 257 | } 258 | end 259 | end 260 | 261 | def substitute_parse_symbols(str) 262 | SYMBOLS.inject(str.downcase) do |s, (symbol, replacement)| 263 | s.gsub(symbol, replacement) 264 | end 265 | end 266 | 267 | 268 | def stepped_range(rng, step = 1) 269 | len = rng.last - rng.first 270 | 271 | num = len.div(step) 272 | result = (0..num).map { |i| rng.first + step * i } 273 | 274 | result.pop if result[-1] == rng.last and rng.exclude_end? 275 | result 276 | end 277 | 278 | 279 | # returns the smallest element from allowed which is greater than current 280 | # returns nil if no matching value was found 281 | def find_best_next(current, allowed, dir) 282 | if dir == :next 283 | allowed.sort.find { |val| val > current } 284 | else 285 | allowed.sort.reverse.find { |val| val < current } 286 | end 287 | end 288 | 289 | def validate_source 290 | unless @source.respond_to?(:split) 291 | raise ArgumentError, 'not a valid cronline' 292 | end 293 | source_length = @source.split(/\s+/).length 294 | unless source_length >= 5 && source_length <= 6 295 | raise ArgumentError, 'not a valid cronline' 296 | end 297 | end 298 | end 299 | -------------------------------------------------------------------------------- /example.cr: -------------------------------------------------------------------------------- 1 | require "./src/cron_parser" 2 | 3 | cron_parser = CronParser.new("30 * * * *") 4 | 5 | # Comming times 6 | p cron_parser.next(Time.now) 7 | p cron_parser.next(Time.now, 5) 8 | 9 | p cron_parser.next(Time.utc_now) 10 | p cron_parser.next(Time.utc_now, 5) 11 | 12 | # Times that have been 13 | p cron_parser.last(Time.now) 14 | p cron_parser.last(Time.now, 5) 15 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: cron_parser 2 | version: 0.4.0 3 | 4 | authors: 5 | - 'Konstantin Makarchev' 6 | 7 | crystal: ">= 0.35.1, < 2.0.0" 8 | 9 | license: MIT 10 | -------------------------------------------------------------------------------- /spec/cron_parser_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | def parse_date(str) 4 | Time.parse(str, "%Y-%m-%d %H:%M", Time::Location.local) 5 | end 6 | 7 | def parse_date_s(str) 8 | Time.parse(str, "%Y-%m-%d %H:%M:%S", Time::Location.local) 9 | end 10 | 11 | describe "CronParser#parse_element" do 12 | [ 13 | {"*", 0..59, (0..59).to_a}, 14 | {"*/10", 0..59, [0, 10, 20, 30, 40, 50]}, 15 | {"10", 0..59, [10]}, 16 | {"10,30", 0..59, [10, 30]}, 17 | {"10-15", 0..59, [10, 11, 12, 13, 14, 15]}, 18 | {"10-40/10", 0..59, [10, 20, 30, 40]}, 19 | ].each do |data| 20 | element, range, expected = data 21 | it "should return #{expected} for '#{element}' when range is #{range}" do 22 | parser = CronParser.new("* * * * *") 23 | parser.parse_element(element, range).values_a.should eq(expected.sort) 24 | end 25 | end 26 | end 27 | 28 | context "CronParser#next" do 29 | [ 30 | {"* * * * *", "2011-08-15 12:00", "2011-08-15 12:01"}, 31 | {"* * * * *", "2011-08-15 02:25", "2011-08-15 02:26"}, 32 | {"* * * * *", "2011-08-15 02:59", "2011-08-15 03:00"}, 33 | {"*/15 * * * *", "2011-08-15 02:02", "2011-08-15 02:15"}, 34 | {"*/15,25 * * * *", "2011-08-15 02:15", "2011-08-15 02:25"}, 35 | {"30 3,6,9 * * *", "2011-08-15 02:15", "2011-08-15 03:30"}, 36 | {"30 9 * * *", "2011-08-15 10:15", "2011-08-16 09:30"}, 37 | {"30 9 * * *", "2011-08-31 10:15", "2011-09-01 09:30"}, 38 | {"30 9 * * *", "2011-09-30 10:15", "2011-10-01 09:30"}, 39 | {"0 9 * * *", "2011-12-31 10:15", "2012-01-01 09:00"}, 40 | {"* * 12 * *", "2010-04-15 10:15", "2010-05-12 00:00"}, 41 | {"* * * * 1,3", "2010-04-15 10:15", "2010-04-19 00:00"}, 42 | {"* * * * MON,WED", "2010-04-15 10:15", "2010-04-19 00:00"}, 43 | {"0 0 1 1 *", "2010-04-15 10:15", "2011-01-01 00:00"}, 44 | {"0 0 * * 1", "2011-08-01 00:00", "2011-08-08 00:00"}, 45 | {"0 0 * * 1", "2011-07-25 00:00", "2011-08-01 00:00"}, 46 | {"45 23 7 3 *", "2011-01-01 00:00", "2011-03-07 23:45"}, 47 | {"0 0 1 jun *", "2013-05-14 11:20", "2013-06-01 00:00"}, 48 | {"0 0 1 may,jul *", "2013-05-14 15:00", "2013-07-01 00:00"}, 49 | {"0 0 1 MAY,JUL *", "2013-05-14 15:00", "2013-07-01 00:00"}, 50 | {"40 5 * * *", "2014-02-01 15:56", "2014-02-02 05:40"}, 51 | {"0 5 * * 1", "2014-02-01 15:56", "2014-02-03 05:00"}, 52 | {"10 8 15 * *", "2014-02-01 15:56", "2014-02-15 08:10"}, 53 | {"50 6 * * 1", "2014-02-01 15:56", "2014-02-03 06:50"}, 54 | {"1 2 * apr mOn", "2014-02-01 15:56", "2014-04-07 02:01"}, 55 | {"1 2 3 4 7", "2014-02-01 15:56", "2014-04-03 02:01"}, 56 | {"1 2 3 4 7", "2014-04-04 15:56", "2014-04-06 02:01"}, 57 | {"1-20/3 * * * *", "2014-02-01 15:56", "2014-02-01 16:01"}, 58 | {"1,2,3 * * * *", "2014-02-01 15:56", "2014-02-01 16:01"}, 59 | {"1-9,15-30 * * * *", "2014-02-01 15:56", "2014-02-01 16:01"}, 60 | {"1-9/3,15-30/4 * * * *", "2014-02-01 15:56", "2014-02-01 16:01"}, 61 | {"1 2 3 jan mon", "2014-02-01 15:56", "2015-01-03 02:01"}, 62 | {"1 2 3 4 mON", "2014-02-01 15:56", "2014-04-03 02:01"}, 63 | {"1 2 3 jan 5", "2014-02-01 15:56", "2015-01-02 02:01"}, 64 | {"@yearly", "2014-02-01 15:56", "2015-01-01 00:00"}, 65 | {"@annually", "2014-02-01 15:56", "2015-01-01 00:00"}, 66 | {"@monthly", "2014-02-01 15:56", "2014-03-01 00:00"}, 67 | {"@weekly", "2014-02-01 15:56", "2014-02-02 00:00"}, 68 | {"@daily", "2014-02-01 15:56", "2014-02-02 00:00"}, 69 | {"@midnight", "2014-02-01 15:56", "2014-02-02 00:00"}, 70 | {"@hourly", "2014-02-01 15:56", "2014-02-01 16:00"}, 71 | {"*/3 * * * *", "2014-02-01 15:56", "2014-02-01 15:57"}, 72 | {"0 5 * 2,3 *", "2014-02-01 15:56", "2014-02-02 05:00"}, 73 | {"15-59/15 * * * *", "2014-02-01 15:56", "2014-02-01 16:15"}, 74 | {"15-59/15 * * * *", "2014-02-01 15:00", "2014-02-01 15:15"}, 75 | {"15-59/15 * * * *", "2014-02-01 15:01", "2014-02-01 15:15"}, 76 | {"15-59/15 * * * *", "2014-02-01 15:16", "2014-02-01 15:30"}, 77 | {"15-59/15 * * * *", "2014-02-01 15:26", "2014-02-01 15:30"}, 78 | {"15-59/15 * * * *", "2014-02-01 15:36", "2014-02-01 15:45"}, 79 | {"15-59/15 * * * *", "2014-02-01 15:45", "2014-02-01 16:15"}, 80 | {"15-59/15 * * * *", "2014-02-01 15:46", "2014-02-01 16:15"}, 81 | {"15-59/15 * * * *", "2014-02-01 15:46", "2014-02-01 16:15"}, 82 | {"*/5 20-24 * * *", "2015-10-31 23:53", "2015-10-31 23:55"}, 83 | {"*/5 20-24 * * *", "2015-10-31 23:55", "2015-11-01 00:00"}, 84 | {"*/5 20-24 * * *", "2015-10-31 23:58", "2015-11-01 20:00"}, 85 | ].each do |data| 86 | line, now, expected_next = data 87 | parser = CronParser.new(line) 88 | parsed_now = parse_date(now) 89 | expected = parse_date(expected_next) 90 | 91 | it "next returns #{expected_next} for '#{line}' when now is #{now}" do 92 | parser.next(parsed_now).should eq(expected) 93 | end 94 | 95 | it "next with num returns array for '#{line}' when now is #{now}" do 96 | res = parser.next(parsed_now, 5) 97 | res.uniq.size.should eq(5) 98 | end 99 | end 100 | 101 | # seconds 102 | [ 103 | {"* * * * * *", "2015-10-31 22:50:50", "2015-10-31 22:50:51"}, 104 | {"* * * * * *", "2015-10-31 22:50:59", "2015-10-31 22:51:00"}, 105 | {"*/2 * * * * *", "2015-10-31 22:51:00", "2015-10-31 22:51:02"}, 106 | {"12 22 * * * *", "2015-10-31 22:51:00", "2015-10-31 23:22:12"}, 107 | {"* * * * *", "2015-10-31 22:51:00", "2015-10-31 22:52:00"}, 108 | {"*/12 * * * * *", "2015-10-31 22:51:55", "2015-10-31 22:52:00"}, 109 | {"*/9 * * * * *", "2015-10-31 22:51:50", "2015-10-31 22:51:54"}, 110 | {"12-24/9 * 23 * * *", "2015-10-31 22:51:50", "2015-10-31 23:00:12"}, 111 | ].each do |data| 112 | line, now, expected_next = data 113 | parser = CronParser.new(line) 114 | parsed_now = parse_date_s(now) 115 | expected = parse_date_s(expected_next) 116 | 117 | it "next returns #{expected_next} for '#{line}' when now is #{now}" do 118 | parser.next(parsed_now).should eq(expected) 119 | end 120 | end 121 | end 122 | 123 | describe "CronParser#last" do 124 | [ 125 | {"* * * * *", "2011-08-15 12:00", "2011-08-15 11:59"}, 126 | {"* * * * *", "2011-08-15 02:25", "2011-08-15 02:24"}, 127 | {"* * * * *", "2011-08-15 03:00", "2011-08-15 02:59"}, 128 | {"*/15 * * * *", "2011-08-15 02:02", "2011-08-15 02:00"}, 129 | {"*/15,45 * * * *", "2011-08-15 02:55", "2011-08-15 02:45"}, 130 | {"*/15,25 * * * *", "2011-08-15 02:35", "2011-08-15 02:30"}, 131 | {"30 3,6,9 * * *", "2011-08-15 02:15", "2011-08-14 09:30"}, 132 | {"30 9 * * *", "2011-08-15 10:15", "2011-08-15 09:30"}, 133 | {"30 9 * * *", "2011-09-01 08:15", "2011-08-31 09:30"}, 134 | {"30 9 * * *", "2011-10-01 08:15", "2011-09-30 09:30"}, 135 | {"0 9 * * *", "2012-01-01 00:15", "2011-12-31 09:00"}, 136 | {"* * 12 * *", "2010-04-15 10:15", "2010-04-12 23:59"}, 137 | {"* * * * 1,3", "2010-04-15 10:15", "2010-04-14 23:59"}, 138 | {"* * * * MON,WED", "2010-04-15 10:15", "2010-04-14 23:59"}, 139 | {"0 0 1 1 *", "2010-04-15 10:15", "2010-01-01 00:00"}, 140 | {"0 0 1 jun *", "2013-05-14 11:20", "2012-06-01 00:00"}, 141 | {"0 0 1 may,jul *", "2013-05-14 15:00", "2013-05-01 00:00"}, 142 | {"0 0 1 MAY,JUL *", "2013-05-14 15:00", "2013-05-01 00:00"}, 143 | {"40 5 * * *", "2014-02-01 15:56", "2014-02-01 05:40"}, 144 | {"0 5 * * 1", "2014-02-01 15:56", "2014-01-27 05:00"}, 145 | {"10 8 15 * *", "2014-02-01 15:56", "2014-01-15 08:10"}, 146 | {"50 6 * * 1", "2014-02-01 15:56", "2014-01-27 06:50"}, 147 | {"1 2 * apr mOn", "2014-02-01 15:56", "2013-04-29 02:01"}, 148 | {"1 2 3 4 7", "2014-02-01 15:56", "2013-04-28 02:01"}, 149 | {"1 2 3 4 7", "2014-04-04 15:56", "2014-04-03 02:01"}, 150 | {"1-20/3 * * * *", "2014-02-01 15:56", "2014-02-01 15:19"}, 151 | {"1,2,3 * * * *", "2014-02-01 15:56", "2014-02-01 15:03"}, 152 | {"1-9,15-30 * * * *", "2014-02-01 15:56", "2014-02-01 15:30"}, 153 | {"1-9/3,15-30/4 * * * *", "2014-02-01 15:56", "2014-02-01 15:27"}, 154 | {"1 2 3 jan mon", "2014-02-01 15:56", "2014-01-27 02:01"}, 155 | {"1 2 3 4 mON", "2014-02-01 15:56", "2013-04-29 02:01"}, 156 | {"1 2 3 jan 5", "2014-02-01 15:56", "2014-01-31 02:01"}, 157 | {"@yearly", "2014-02-01 15:56", "2014-01-01 00:00"}, 158 | {"@annually", "2014-02-01 15:56", "2014-01-01 00:00"}, 159 | {"@monthly", "2014-02-01 15:56", "2014-02-01 00:00"}, 160 | {"@weekly", "2014-02-01 15:56", "2014-01-26 00:00"}, 161 | {"@daily", "2014-02-01 15:56", "2014-02-01 00:00"}, 162 | {"@midnight", "2014-02-01 15:56", "2014-02-01 00:00"}, 163 | {"@hourly", "2014-02-01 15:56", "2014-02-01 15:00"}, 164 | {"*/3 * * * *", "2014-02-01 15:56", "2014-02-01 15:54"}, 165 | {"0 5 * 2,3 *", "2014-02-01 15:56", "2014-02-01 05:00"}, 166 | {"15-59/15 * * * *", "2014-02-01 15:56", "2014-02-01 15:45"}, 167 | {"15-59/15 * * * *", "2014-02-01 15:00", "2014-02-01 14:45"}, 168 | {"15-59/15 * * * *", "2014-02-01 15:01", "2014-02-01 14:45"}, 169 | {"15-59/15 * * * *", "2014-02-01 15:16", "2014-02-01 15:15"}, 170 | {"15-59/15 * * * *", "2014-02-01 15:26", "2014-02-01 15:15"}, 171 | {"15-59/15 * * * *", "2014-02-01 15:36", "2014-02-01 15:30"}, 172 | {"15-59/15 * * * *", "2014-02-01 15:45", "2014-02-01 15:30"}, 173 | {"15-59/15 * * * *", "2014-02-01 15:46", "2014-02-01 15:45"}, 174 | ].each do |data| 175 | line, now, expected_next = data 176 | parser = CronParser.new(line) 177 | parsed_now = parse_date(now) 178 | expected = parse_date(expected_next) 179 | 180 | it "last returns #{expected_next} for '#{line}' when now is #{now}" do 181 | parser.last(parsed_now).should eq(expected) 182 | end 183 | 184 | it "last with num returns array for '#{line}' when now is #{now}" do 185 | res = parser.last(parsed_now, 5) 186 | res.uniq.size.should eq(5) 187 | end 188 | end 189 | 190 | # seconds 191 | [ 192 | {"* * * * * *", "2015-10-31 22:51:00", "2015-10-31 22:50:59"}, 193 | {"*/2 * * * * *", "2015-10-31 22:51:00", "2015-10-31 22:50:58"}, 194 | {"12 22 * * * *", "2015-10-31 22:51:00", "2015-10-31 22:22:12"}, 195 | {"* * * * *", "2015-10-31 22:51:00", "2015-10-31 22:50:00"}, 196 | {"*/12 * * * * *", "2015-10-31 22:51:55", "2015-10-31 22:51:48"}, 197 | {"*/9 * * * * *", "2015-10-31 22:51:50", "2015-10-31 22:51:45"}, 198 | {"12-24/9 * 23 * * *", "2015-10-31 22:51:50", "2015-10-30 23:59:21"}, 199 | ].each do |data| 200 | line, now, expected_next = data 201 | parser = CronParser.new(line) 202 | parsed_now = parse_date_s(now) 203 | expected = parse_date_s(expected_next) 204 | 205 | it "next returns #{expected_next} for '#{line}' when now is #{now}" do 206 | parser.last(parsed_now).should eq(expected) 207 | end 208 | end 209 | end 210 | 211 | describe "CronParser#new" do 212 | it "should not raise error when given a valid cronline" do 213 | CronParser.new("30 * * * *") 214 | end 215 | 216 | it "should raise error when given an invalid cronline" do 217 | expect_raises(ArgumentError) do 218 | CronParser.new("* * * *") 219 | end 220 | end 221 | end 222 | -------------------------------------------------------------------------------- /spec/spec_helper.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | require "../src/cron_parser" 3 | -------------------------------------------------------------------------------- /src/cron_parser.cr: -------------------------------------------------------------------------------- 1 | class CronParser 2 | VERSION = "0.4.0" 3 | 4 | class InternalTime 5 | property year : Int32 6 | property month : Int32 7 | property day : Int32 8 | property hour : Int32 9 | property min : Int32 10 | property second : Int32 11 | property location : Time::Location 12 | 13 | def initialize(time) 14 | @year = time.year 15 | @month = time.month 16 | @day = time.day 17 | @hour = time.hour 18 | @min = time.minute 19 | @second = time.second 20 | @location = time.location 21 | end 22 | 23 | def to_time 24 | Time.local(@year, @month, @day, @hour, @min, @second, nanosecond: 0, location: @location) 25 | end 26 | end 27 | 28 | @source : String 29 | @time_specs : TimeSpec 30 | 31 | def initialize(source) 32 | @source = interpret_vixieisms(source.strip) 33 | @_interpolate_weekdays_cache = {} of Tuple(Int32, Int32) => Array(Int32) 34 | validate_source 35 | @time_specs = calc_time_spec 36 | end 37 | 38 | # returns the next occurence after the given date 39 | def next(now = Time.local) 40 | t = InternalTime.new(now) 41 | 42 | unless time_specs.month.values.includes?(t.month) 43 | nudge_month(t) 44 | t.day = 0 45 | end 46 | 47 | unless interpolate_weekdays(t.year, t.month).includes?(t.day) 48 | nudge_date(t) 49 | t.hour = -1 50 | end 51 | 52 | unless time_specs.hour.values.includes?(t.hour) 53 | nudge_hour(t) 54 | t.min = -1 55 | end 56 | 57 | unless time_specs.second 58 | nudge_minute(t) 59 | t.second = 0 60 | else 61 | unless time_specs.minute.values.includes?(t.min) 62 | nudge_minute(t) 63 | t.second = -1 64 | end 65 | 66 | nudge_second(t) 67 | end 68 | 69 | t.to_time 70 | end 71 | 72 | # returns the last occurence before the given date 73 | def last(now = Time.local) 74 | t = InternalTime.new(now) 75 | 76 | unless time_specs.month.values.includes?(t.month) 77 | nudge_month(t, :last) 78 | t.day = 32 79 | end 80 | 81 | if t.day == 32 || !interpolate_weekdays(t.year, t.month).includes?(t.day) 82 | nudge_date(t, :last) 83 | t.hour = 24 84 | end 85 | 86 | unless time_specs.hour.values.includes?(t.hour) 87 | nudge_hour(t, :last) 88 | t.min = 60 89 | end 90 | 91 | unless time_specs.second 92 | nudge_minute(t, :last) 93 | t.second = 0 94 | else 95 | unless time_specs.minute.values.includes?(t.min) 96 | nudge_minute(t, :last) 97 | t.second = 60 98 | end 99 | 100 | nudge_second(t, :last) 101 | end 102 | 103 | t.to_time 104 | end 105 | 106 | macro array_result(name) 107 | def {{ name.id }}(now : Time, num : Int32) 108 | res = [] of Time 109 | n = self.{{ name.id }}(now) 110 | res << n 111 | (num - 1).times do 112 | n = self.{{ name.id }}(n) 113 | res << n 114 | end 115 | res 116 | end 117 | end 118 | 119 | array_result :next 120 | array_result :last 121 | 122 | SUBELEMENT_REGEX = %r{^(\d+)(-(\d+)(/(\d+))?)?$} 123 | 124 | record Element, values : Set(Int32), values_a : Array(Int32), elem : String 125 | 126 | def parse_element(elem, allowed_range) 127 | values = elem.split(",").map do |subel| 128 | if subel =~ /^\*/ 129 | step = subel.size > 1 ? subel[2..-1].to_i : 1 130 | stepped_range(allowed_range, step, allowed_range) 131 | else 132 | if m = subel.match(SUBELEMENT_REGEX) 133 | if m[5]? # with range 134 | stepped_range(m[1].to_i..m[3].to_i, m[5].to_i, allowed_range) 135 | elsif m[3]? # range without step 136 | stepped_range(m[1].to_i..m[3].to_i, 1, allowed_range) 137 | else # just a numeric 138 | [m[1].to_i] 139 | end 140 | else 141 | raise ArgumentError.new("Bad Vixie-style specification #{subel}") 142 | end 143 | end 144 | end.flatten.sort 145 | 146 | Element.new(Set.new(values), values.sort, elem) 147 | end 148 | 149 | # protected 150 | 151 | # returns a list of days which do both match time_spec[:dom] or time_spec[:dow] 152 | private def interpolate_weekdays(year, month) 153 | @_interpolate_weekdays_cache[{year, month}] ||= interpolate_weekdays_without_cache(year, month) 154 | end 155 | 156 | private def interpolate_weekdays_without_cache(year, month) 157 | t = Time.local(year, month, 1) 158 | valid_mday, mday_field = time_specs.dom.values, time_specs.dom.elem 159 | valid_wday, wday_field = time_specs.dow.values, time_specs.dow.elem 160 | 161 | # Careful, if both DOW and DOM fields are non-wildcard, 162 | # then we only need to match *one* for cron to run the job: 163 | if !(mday_field == "*" && wday_field == "*") 164 | valid_mday.clear if mday_field == "*" 165 | valid_wday.clear if wday_field == "*" 166 | end 167 | 168 | # Careful: crontabs may use either 0 or 7 for Sunday: 169 | valid_wday << 0 if valid_wday.includes?(7) 170 | 171 | result = [] of Int32 172 | 173 | while t.month == month 174 | wday = t.day_of_week.to_i 175 | wday = 0 if wday == 7 176 | result << t.day if valid_mday.includes?(t.day) || valid_wday.includes?(wday) 177 | t += 1.day 178 | end 179 | 180 | result.sort 181 | end 182 | 183 | private def nudge_year(t, dir = :next) 184 | t.year += (dir == :next) ? 1 : -1 185 | end 186 | 187 | private def nudge_month(t, dir = :next) 188 | spec = time_specs.month.values_a 189 | next_value = find_best_next(t.month, spec, dir) 190 | t.month = next_value || (dir == :next ? spec.first : spec.last) 191 | 192 | nudge_year(t, dir) unless next_value 193 | 194 | # we changed the month, so its likely that the date is incorrect now 195 | valid_days = interpolate_weekdays(t.year, t.month) 196 | t.day = (dir == :next) ? valid_days.first : valid_days.last 197 | end 198 | 199 | private def nudge_date(t, dir = :next, can_nudge_month = true) 200 | spec = interpolate_weekdays(t.year, t.month) 201 | next_value = find_best_next(t.day, spec, dir) 202 | t.day = next_value || (dir == :next ? spec.first : spec.last) 203 | 204 | nudge_month(t, dir) if next_value.nil? && can_nudge_month 205 | end 206 | 207 | private def nudge_hour(t, dir = :next) 208 | spec = time_specs.hour.values_a 209 | next_value = find_best_next(t.hour, spec, dir) 210 | t.hour = next_value || (dir == :next ? spec.first : spec.last) 211 | 212 | nudge_date(t, dir) if next_value.nil? 213 | end 214 | 215 | private def nudge_minute(t, dir = :next) 216 | spec = time_specs.minute.values_a 217 | next_value = find_best_next(t.min, spec, dir) 218 | t.min = next_value || (dir == :next ? spec.first : spec.last) 219 | 220 | nudge_hour(t, dir) if next_value.nil? 221 | end 222 | 223 | private def nudge_second(t, dir = :next) 224 | if second = time_specs.second 225 | spec = second.values_a 226 | next_value = find_best_next(t.second, spec, dir) 227 | t.second = next_value || (dir == :next ? spec.first : spec.last) 228 | 229 | nudge_minute(t, dir) if next_value.nil? 230 | end 231 | end 232 | 233 | record TimeSpec, minute : Element, hour : Element, dom : Element, month : Element, dow : Element do 234 | property second : Element? 235 | end 236 | 237 | private def calc_time_spec 238 | # tokens now contains the 5 fields 239 | tokens = substitute_parse_symbols(@source).split(/\s+/) 240 | 241 | # if tokens has 6 parts, we parse first one as seconds (EXTRA syntax) 242 | second = if tokens.size == 6 243 | tokens.shift 244 | end 245 | 246 | res = TimeSpec.new( 247 | parse_element(tokens[0], 0..59), # minute 248 | parse_element(tokens[1], 0..23), # hour 249 | parse_element(tokens[2], 1..31), # DOM 250 | parse_element(tokens[3], 1..12), # mon 251 | parse_element(tokens[4], 0..6), # DOW 252 | ) 253 | 254 | if second 255 | res.second = parse_element(second, 0..59) # second [optional] 256 | end 257 | 258 | res 259 | end 260 | 261 | SYMBOLS = { 262 | "jan" => "1", 263 | "feb" => "2", 264 | "mar" => "3", 265 | "apr" => "4", 266 | "may" => "5", 267 | "jun" => "6", 268 | "jul" => "7", 269 | "aug" => "8", 270 | "sep" => "9", 271 | "oct" => "10", 272 | "nov" => "11", 273 | "dec" => "12", 274 | 275 | "sun" => "0", 276 | "mon" => "1", 277 | "tue" => "2", 278 | "wed" => "3", 279 | "thu" => "4", 280 | "fri" => "5", 281 | "sat" => "6", 282 | } 283 | 284 | private def substitute_parse_symbols(str) 285 | s = str.downcase 286 | SYMBOLS.each do |from, to| 287 | s = s.gsub(from, to) 288 | end 289 | s 290 | end 291 | 292 | private def stepped_range(rng, step, allowed_range) 293 | first = rng.begin 294 | last = rng.end 295 | first = allowed_range.begin if first < allowed_range.begin 296 | last = allowed_range.end if last > allowed_range.end 297 | len = last - first 298 | 299 | num = len./(step) 300 | result = (0..num).map { |i| first + step * i } 301 | 302 | result.pop if result[-1] == last && rng.exclusive? 303 | result 304 | end 305 | 306 | # returns the smallest element from allowed which is greater than current 307 | # returns nil if no matching value was found 308 | private def find_best_next(current, allowed, dir) 309 | if dir == :next 310 | allowed.each { |val| return val if val > current } 311 | else 312 | allowed.reverse_each { |val| return val if val < current } 313 | end 314 | nil 315 | end 316 | 317 | private def validate_source 318 | unless @source.responds_to?(:split) 319 | raise ArgumentError.new("not a valid cronline") 320 | end 321 | source_length = @source.split(/\s+/).size 322 | unless source_length >= 5 && source_length <= 6 323 | raise ArgumentError.new("not a valid cronline") 324 | end 325 | end 326 | 327 | private def interpret_vixieisms(spec) 328 | case spec 329 | when "@reboot" 330 | raise ArgumentError.new("Can't predict last/next run of @reboot") 331 | when "@yearly", "@annually" 332 | "0 0 1 1 *" 333 | when "@monthly" 334 | "0 0 1 * *" 335 | when "@weekly" 336 | "0 0 * * 0" 337 | when "@daily", "@midnight" 338 | "0 0 * * *" 339 | when "@hourly" 340 | "0 * * * *" 341 | else 342 | spec 343 | end 344 | end 345 | 346 | private def time_specs 347 | @time_specs 348 | end 349 | end 350 | --------------------------------------------------------------------------------