hron 0.6.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/hron/cron.rb CHANGED
@@ -2,746 +2,463 @@
2
2
 
3
3
  require_relative "ast"
4
4
  require_relative "error"
5
+ require_relative "evaluator"
5
6
 
6
7
  module Hron
7
- # Cron conversion module
8
8
  module Cron
9
- CRON_DOW_MAP = {
10
- 0 => Weekday::SUNDAY,
11
- 1 => Weekday::MONDAY,
12
- 2 => Weekday::TUESDAY,
13
- 3 => Weekday::WEDNESDAY,
14
- 4 => Weekday::THURSDAY,
15
- 5 => Weekday::FRIDAY,
16
- 6 => Weekday::SATURDAY,
17
- 7 => Weekday::SUNDAY
9
+ MAX_LISTED_TIMES = 24
10
+ BOTH_DAYS_RESTRICTED =
11
+ "not expressible in hron: cron fires on either the day of month or the day of week"
12
+ INTERVAL_DAYS =
13
+ "not expressible in hron: an interval runs only on every day, weekdays, the weekend or listed days"
14
+ MINUTES_PER_DAY = 24 * 60
15
+ MIDNIGHT = TimeOfDay.new(0, 0)
16
+ END_OF_DAY = TimeOfDay.new(23, 59)
17
+
18
+ # Digit strings may be of any length. Every number at or above this cap is out
19
+ # of every field's range and steps past every range's end, so saturating at it
20
+ # keeps each comparison exact.
21
+ NUMBER_CAP = 1000
22
+
23
+ MONTH_NAMES = %w[jan feb mar apr may jun jul aug sep oct nov dec].freeze
24
+ DAY_NAMES = %w[sun mon tue wed thu fri sat].freeze
25
+ WEEKDAYS = [
26
+ Weekday::SUNDAY, Weekday::MONDAY, Weekday::TUESDAY, Weekday::WEDNESDAY,
27
+ Weekday::THURSDAY, Weekday::FRIDAY, Weekday::SATURDAY
28
+ ].freeze
29
+ ORDINALS = [
30
+ OrdinalPosition::FIRST, OrdinalPosition::SECOND, OrdinalPosition::THIRD,
31
+ OrdinalPosition::FOURTH, OrdinalPosition::FIFTH
32
+ ].freeze
33
+
34
+ SHORTCUTS = {
35
+ "@yearly" => "0 0 1 1 *",
36
+ "@annually" => "0 0 1 1 *",
37
+ "@monthly" => "0 0 1 * *",
38
+ "@weekly" => "0 0 * * 0",
39
+ "@daily" => "0 0 * * *",
40
+ "@midnight" => "0 0 * * *",
41
+ "@hourly" => "0 * * * *"
18
42
  }.freeze
19
43
 
20
- def self.to_cron(schedule)
21
- raise HronError.cron("not expressible as cron (except clauses not supported)") unless schedule.except.empty?
22
-
23
- raise HronError.cron("not expressible as cron (until clauses not supported)") if schedule.until
24
-
25
- raise HronError.cron("not expressible as cron (during clauses not supported)") unless schedule.during.empty?
26
-
27
- expr = schedule.expr
28
-
29
- case expr
30
- when DayRepeat
31
- raise HronError.cron("not expressible as cron (multi-day intervals not supported)") if expr.interval > 1
32
- raise HronError.cron("not expressible as cron (multiple times not supported)") if expr.times.length != 1
33
-
34
- time = expr.times[0]
35
- dow = day_filter_to_cron_dow(expr.days)
36
- "#{time.minute} #{time.hour} * * #{dow}"
37
-
38
- when IntervalRepeat
39
- full_day = expr.from_time.hour.zero? && expr.from_time.minute.zero? &&
40
- expr.to_time.hour == 23 && expr.to_time.minute == 59
41
- raise HronError.cron("not expressible as cron (partial-day interval windows not supported)") unless full_day
42
-
43
- raise HronError.cron("not expressible as cron (interval with day filter not supported)") if expr.day_filter
44
-
45
- if expr.unit == IntervalUnit::MIN
46
- if (60 % expr.interval) != 0
47
- raise HronError.cron("not expressible as cron (*/#{expr.interval} breaks at hour boundaries)")
48
- end
49
-
50
- "*/#{expr.interval} * * * *"
44
+ Field = Data.define(:name, :min, :max, :star_end, :names)
45
+ MINUTE = Field.new("minute", 0, 59, 59, [])
46
+ HOUR = Field.new("hour", 0, 23, 23, [])
47
+ DAY_OF_MONTH = Field.new("day of month", 1, 31, 31, [])
48
+ MONTH = Field.new("month", 1, 12, 12, MONTH_NAMES)
49
+ # 7 is Sunday only where written: `*` and `a/n` end at 6.
50
+ DAY_OF_WEEK = Field.new("day of week", 0, 7, 6, DAY_NAMES)
51
+
52
+ Item = Data.define(:bounds, :step)
53
+
54
+ private_constant :MAX_LISTED_TIMES, :BOTH_DAYS_RESTRICTED, :INTERVAL_DAYS, :MINUTES_PER_DAY,
55
+ :MIDNIGHT, :END_OF_DAY, :NUMBER_CAP, :MONTH_NAMES, :DAY_NAMES, :WEEKDAYS, :ORDINALS,
56
+ :SHORTCUTS, :Field, :MINUTE, :HOUR, :DAY_OF_MONTH, :MONTH, :DAY_OF_WEEK, :Item
57
+
58
+ class << self
59
+ def from_cron(input)
60
+ text = trim(utf8(input))
61
+ text = shortcut(text) if text.start_with?("@")
62
+ fields = text.split(/[ \t]+/)
63
+ raise HronError.cron("expected 5 cron fields, got #{fields.length}") unless fields.length == 5
64
+
65
+ minute, hour, day_of_month, month, day_of_week = fields
66
+ minutes = values(minute, MINUTE).sort
67
+ hours = values(hour, HOUR).sort
68
+ month_days = parse_day_of_month(day_of_month)
69
+ months = values(month, MONTH).sort
70
+ week_days = parse_day_of_week(day_of_week)
71
+ days = day_expression(month_days, week_days)
72
+ times = hours.flat_map { |h| minutes.map { |m| TimeOfDay.new(h, m) } }
73
+
74
+ gap = equal_gap(times)
75
+ expr = if days.key?(:of_week) && gap
76
+ interval(times, gap, days[:of_week])
77
+ elsif times.length > MAX_LISTED_TIMES
78
+ raise too_many_times(times.length, gap)
79
+ elsif (target = year_target(days, months))
80
+ YearRepeat.new(1, target, times)
81
+ elsif days.key?(:of_week)
82
+ DayRepeat.new(1, days[:of_week], times)
51
83
  else
52
- "0 */#{expr.interval} * * *"
84
+ MonthRepeat.new(1, days[:of_month], times)
53
85
  end
86
+ yearly = expr.is_a?(YearRepeat)
87
+ during = (!yearly && months.length < MonthName::ALL.length) ? months.map { |m| MonthName::ALL[m - 1] } : []
88
+ ScheduleData.new(expression: expr, during: during)
89
+ end
54
90
 
55
- when WeekRepeat
56
- raise HronError.cron("not expressible as cron (multi-week intervals not supported)")
57
-
58
- when MonthRepeat
59
- raise HronError.cron("not expressible as cron (multi-month intervals not supported)") if expr.interval > 1
60
- raise HronError.cron("not expressible as cron (multiple times not supported)") if expr.times.length != 1
91
+ def to_cron(schedule)
92
+ raise not_expressible("except clauses not supported") unless schedule.except.empty?
93
+ raise not_expressible("until clauses not supported") if schedule.until
94
+ raise not_expressible("starting clauses not supported") if schedule.starting
61
95
 
62
- time = expr.times[0]
63
- case expr.target
64
- when DaysTarget
65
- expanded = []
66
- expr.target.specs.each do |s|
67
- case s
68
- when SingleDay
69
- expanded << s.day
70
- when DayRange
71
- (s.start..s.end_day).each { |d| expanded << d }
72
- end
73
- end
74
- dom = expanded.join(",")
75
- "#{time.minute} #{time.hour} #{dom} * *"
76
- when LastDayTarget
77
- raise HronError.cron("not expressible as cron (last day of month not supported)")
78
- when LastWeekdayTarget
79
- raise HronError.cron("not expressible as cron (last weekday of month not supported)")
80
- when NearestWeekdayTarget
81
- if expr.target.direction
82
- raise HronError.cron("not expressible as cron (directional nearest weekday not supported)")
83
- end
84
-
85
- "#{time.minute} #{time.hour} #{expr.target.day}W * *"
86
- when OrdinalWeekdayTarget
87
- raise HronError.cron("not expressible as cron (ordinal weekday of month not supported)")
88
- else
89
- raise HronError.cron("not expressible as cron (unsupported month target)")
90
- end
96
+ day_of_month, day_of_week = day_fields(schedule.expression)
97
+ month = month_field(schedule)
98
+ minute, hour = time_fields(schedule.expression)
99
+ "#{minute} #{hour} #{day_of_month} #{month} #{day_of_week}"
100
+ end
91
101
 
92
- when SingleDateExpr
93
- raise HronError.cron("not expressible as cron (single dates are not repeating)")
102
+ private
94
103
 
95
- when YearRepeat
96
- raise HronError.cron("not expressible as cron (yearly schedules not supported in 5-field cron)")
104
+ # Error messages echo the input, so they must be valid UTF-8. No byte outside ASCII
105
+ # is valid cron, so replacing one changes no result. BINARY text, and text in an
106
+ # encoding Ruby has no converter for, is read as UTF-8.
107
+ def utf8(input)
108
+ return as_utf8(input) if input.encoding == Encoding::BINARY
97
109
 
98
- else
99
- raise HronError.cron("unknown expression type: #{expr.class}")
110
+ input.encode(Encoding::UTF_8, invalid: :replace, undef: :replace).scrub
111
+ rescue Encoding::ConverterNotFoundError
112
+ as_utf8(input)
100
113
  end
101
- end
102
114
 
103
- def self.day_filter_to_cron_dow(filter)
104
- case filter
105
- when DayFilterEvery
106
- "*"
107
- when DayFilterWeekday
108
- "1-5"
109
- when DayFilterWeekend
110
- "0,6"
111
- when DayFilterDays
112
- nums = filter.days.map { |d| Weekday.cron_dow(d) }.sort
113
- nums.join(",")
115
+ def as_utf8(input)
116
+ input.dup.force_encoding(Encoding::UTF_8).scrub
114
117
  end
115
- end
116
-
117
- DOW_NAME_MAP = {
118
- "SUN" => 0, "MON" => 1, "TUE" => 2, "WED" => 3,
119
- "THU" => 4, "FRI" => 5, "SAT" => 6
120
- }.freeze
121
118
 
122
- MONTH_NAME_MAP = {
123
- "JAN" => 1, "FEB" => 2, "MAR" => 3, "APR" => 4,
124
- "MAY" => 5, "JUN" => 6, "JUL" => 7, "AUG" => 8,
125
- "SEP" => 9, "OCT" => 10, "NOV" => 11, "DEC" => 12
126
- }.freeze
127
-
128
- def self.from_cron(cron_str)
129
- cron_str = cron_str.strip
130
-
131
- # Handle @ shortcuts first
132
- return parse_cron_shortcut(cron_str) if cron_str.start_with?("@")
133
-
134
- fields = cron_str.split
135
- raise HronError.cron("expected 5 cron fields, got #{fields.length}") if fields.length != 5
136
-
137
- minute_field, hour_field, dom_field, month_field, dow_field = fields
138
-
139
- # Normalize ? to * (they're semantically equivalent for our purposes)
140
- dom_field = "*" if dom_field == "?"
141
- dow_field = "*" if dow_field == "?"
119
+ # String#strip would also strip NUL, vertical tab and form feed.
120
+ def trim(text)
121
+ first = text.index(/[^ \t\r\n]/)
122
+ first ? text[first..text.rindex(/[^ \t\r\n]/)] : ""
123
+ end
142
124
 
143
- # Parse month field into during clause
144
- during = parse_month_field(month_field)
125
+ def shortcut(input)
126
+ SHORTCUTS.fetch(input.downcase(:ascii)) do
127
+ raise HronError.cron("unknown cron shortcut: #{input}")
128
+ end
129
+ end
145
130
 
146
- # Check for special DOW patterns: nth weekday (#), last weekday (5L)
147
- result = try_parse_nth_weekday(minute_field, hour_field, dom_field, dow_field, during)
148
- return result if result
131
+ def parse_day_of_month(text)
132
+ return :any if text == "*" || text == "?"
133
+ return :last if text.downcase(:ascii) == "l"
134
+ return :last_weekday if text.downcase(:ascii) == "lw"
149
135
 
150
- # Check for L (last day) or LW (last weekday) in DOM
151
- result = try_parse_last_day(minute_field, hour_field, dom_field, dow_field, during)
152
- return result if result
136
+ day = text[0...-1]
137
+ return {nearest: field_value(day, DAY_OF_MONTH)} if text.end_with?("W", "w") && number?(day)
153
138
 
154
- # Check for W (nearest weekday): e.g., 15W
155
- if dom_field.end_with?("W") && dom_field != "LW"
156
- result = try_parse_nearest_weekday(minute_field, hour_field, dom_field, dow_field, during)
157
- return result if result
139
+ {days: values(text, DAY_OF_MONTH)}
158
140
  end
159
141
 
160
- # Check for interval patterns: */N or range/N
161
- result = try_parse_interval(minute_field, hour_field, dom_field, dow_field, during)
162
- return result if result
163
-
164
- # Standard time-based cron
165
- minute = parse_single_value(minute_field, "minute", 0, 59)
166
- hour = parse_single_value(hour_field, "hour", 0, 23)
167
- time = TimeOfDay.new(hour, minute)
142
+ def parse_day_of_week(text)
143
+ return :any if text == "*" || text == "?"
168
144
 
169
- # DOM-based (monthly) - when DOM is specified and DOW is *
170
- if dom_field != "*" && dow_field == "*"
171
- target = parse_dom_field(dom_field)
172
- return ScheduleData.new(
173
- expr: MonthRepeat.new(1, target, [time]),
174
- during: during
175
- )
176
- end
145
+ day, hash, nth = text.partition("#")
146
+ if !hash.empty? && value?(day, DAY_OF_WEEK) && number?(nth)
147
+ weekday = WEEKDAYS[field_value(day, DAY_OF_WEEK) % 7]
148
+ n = number(nth)
149
+ raise HronError.cron("day of week ordinal must be 1-5, got #{nth}") unless n.between?(1, 5)
177
150
 
178
- # DOW-based (day repeat)
179
- days = parse_cron_dow(dow_field)
180
- ScheduleData.new(
181
- expr: DayRepeat.new(1, days, [time]),
182
- during: during
183
- )
184
- end
151
+ return {nth: n, weekday: weekday}
152
+ end
153
+ day = text[0...-1]
154
+ return {last: WEEKDAYS[field_value(day, DAY_OF_WEEK) % 7]} if text.end_with?("L", "l") && value?(day, DAY_OF_WEEK)
185
155
 
186
- # Parse @ shortcuts like @daily, @hourly, etc.
187
- def self.parse_cron_shortcut(cron_str)
188
- case cron_str.downcase
189
- when "@yearly", "@annually"
190
- ScheduleData.new(
191
- expr: YearRepeat.new(
192
- 1,
193
- YearDateTarget.new(MonthName::JAN, 1),
194
- [TimeOfDay.new(0, 0)]
195
- )
196
- )
197
- when "@monthly"
198
- ScheduleData.new(
199
- expr: MonthRepeat.new(
200
- 1,
201
- DaysTarget.new([SingleDay.new(1)]),
202
- [TimeOfDay.new(0, 0)]
203
- )
204
- )
205
- when "@weekly"
206
- ScheduleData.new(
207
- expr: DayRepeat.new(
208
- 1,
209
- DayFilterDays.new([Weekday::SUNDAY]),
210
- [TimeOfDay.new(0, 0)]
211
- )
212
- )
213
- when "@daily", "@midnight"
214
- ScheduleData.new(
215
- expr: DayRepeat.new(
216
- 1,
217
- DayFilterEvery.new,
218
- [TimeOfDay.new(0, 0)]
219
- )
220
- )
221
- when "@hourly"
222
- ScheduleData.new(
223
- expr: IntervalRepeat.new(
224
- 1,
225
- IntervalUnit::HOURS,
226
- TimeOfDay.new(0, 0),
227
- TimeOfDay.new(23, 59),
228
- nil
229
- )
230
- )
231
- else
232
- raise HronError.cron("unknown @ shortcut: #{cron_str}")
156
+ {days: values(text, DAY_OF_WEEK)}
233
157
  end
234
- end
235
158
 
236
- # Parse month field into a Vec<MonthName> for the `during` clause.
237
- def self.parse_month_field(field)
238
- return [] if field == "*"
239
-
240
- months = []
241
- field.split(",").each do |part|
242
- # Check for step values FIRST (e.g., 1-12/3 or */3)
243
- if part.include?("/")
244
- range_part, step_str = part.split("/", 2)
245
- if range_part == "*"
246
- start_num = 1
247
- end_num = 12
248
- elsif range_part.include?("-")
249
- s, e = range_part.split("-", 2)
250
- start_num = MonthName.number(parse_month_value(s))
251
- end_num = MonthName.number(parse_month_value(e))
252
- else
253
- raise HronError.cron("invalid month step expression: #{part}")
159
+ # Keeps the order of first appearance, in which from_cron lists days of the week.
160
+ def values(text, field)
161
+ items = items(text, field)
162
+ raise HronError.cron("invalid #{field.name}: #{text}") unless items
163
+
164
+ values = []
165
+ items.each do |item|
166
+ first, last = case item.bounds
167
+ in :star
168
+ [field.min, field.star_end]
169
+ in [a]
170
+ first = field_value(a, field)
171
+ # `7/n` starts past the end of `*`, so it is Sunday alone.
172
+ [first, item.step ? [first, field.star_end].max : first]
173
+ in [a, b]
174
+ first = field_value(a, field)
175
+ last = field_value(b, field)
176
+ raise HronError.cron("#{field.name} range must not run backwards: #{a}-#{b}") if first > last
177
+
178
+ [first, last]
254
179
  end
180
+ step = item.step ? number(item.step) : 1
181
+ raise HronError.cron("#{field.name} step must be at least 1") if step.zero?
255
182
 
256
- step = begin
257
- Integer(step_str)
258
- rescue
259
- raise(HronError.cron("invalid month step value: #{step_str}"))
183
+ first.step(last, step) do |value|
184
+ value %= 7 if field == DAY_OF_WEEK
185
+ values << value unless values.include?(value)
260
186
  end
261
- raise HronError.cron("step cannot be 0") if step == 0
187
+ end
188
+ values
189
+ end
262
190
 
263
- n = start_num
264
- while n <= end_num
265
- months << month_from_number(n)
266
- n += step
191
+ def items(text, field)
192
+ text.split(",", -1).map do |item|
193
+ range, slash, step = item.partition("/")
194
+ step = nil if slash.empty?
195
+ first, dash, last = range.partition("-")
196
+ bounds = if range == "*"
197
+ :star
198
+ elsif dash.empty?
199
+ [range]
200
+ else
201
+ [first, last]
267
202
  end
268
- elsif part.include?("-")
269
- # Range like 1-3 or JAN-MAR
270
- start_str, end_str = part.split("-", 2)
271
- start_month = parse_month_value(start_str)
272
- end_month = parse_month_value(end_str)
273
- start_num = MonthName.number(start_month)
274
- end_num = MonthName.number(end_month)
275
- raise HronError.cron("invalid month range: #{start_str} > #{end_str}") if start_num > end_num
276
-
277
- (start_num..end_num).each { |n| months << month_from_number(n) }
278
- else
279
- # Single month
280
- months << parse_month_value(part)
203
+ valid = (step.nil? || number?(step)) && (bounds == :star || bounds.all? { |value| value?(value, field) })
204
+ return nil unless valid
205
+
206
+ Item.new(bounds, step)
281
207
  end
282
208
  end
283
209
 
284
- months
285
- end
286
-
287
- # Parse a single month value (number 1-12 or name JAN-DEC).
288
- def self.parse_month_value(s)
289
- # Try as number first
290
- if /^\d+$/.match?(s)
291
- n = Integer(s)
292
- return month_from_number(n)
210
+ def number?(text)
211
+ text.match?(/\A[0-9]+\z/)
293
212
  end
294
- # Try as name
295
- n = MONTH_NAME_MAP[s.upcase]
296
- raise HronError.cron("invalid month: #{s}") unless n
297
213
 
298
- month_from_number(n)
299
- end
214
+ def value?(text, field)
215
+ number?(text) || !name_value(text, field).nil?
216
+ end
300
217
 
301
- def self.month_from_number(n)
302
- month = MonthName.from_number(n)
303
- raise HronError.cron("invalid month number: #{n}") unless month
218
+ def name_value(text, field)
219
+ index = field.names.index(text.downcase(:ascii))
220
+ index && index + field.min
221
+ end
304
222
 
305
- month
306
- end
223
+ def number(digits)
224
+ digits.each_byte.reduce(0) { |n, digit| [n * 10 + digit - "0".ord, NUMBER_CAP].min }
225
+ end
307
226
 
308
- # Try to parse nth weekday patterns like 1#1 (first Monday) or 5L (last Friday).
309
- def self.try_parse_nth_weekday(minute_field, hour_field, dom_field, dow_field, during)
310
- # Check for # pattern (nth weekday of month)
311
- if dow_field.include?("#")
312
- dow_str, nth_str = dow_field.split("#", 2)
313
- dow_num = parse_dow_value(dow_str)
314
- weekday = cron_dow_to_weekday(dow_num)
315
- nth = begin
316
- Integer(nth_str)
317
- rescue
318
- raise(HronError.cron("invalid nth value: #{nth_str}"))
319
- end
320
- raise HronError.cron("nth must be 1-5, got #{nth}") if nth < 1 || nth > 5
321
-
322
- ordinal = case nth
323
- when 1 then OrdinalPosition::FIRST
324
- when 2 then OrdinalPosition::SECOND
325
- when 3 then OrdinalPosition::THIRD
326
- when 4 then OrdinalPosition::FOURTH
327
- when 5 then OrdinalPosition::FIFTH
227
+ def field_value(text, field)
228
+ value = name_value(text, field) || number(text)
229
+ unless value.between?(field.min, field.max)
230
+ raise HronError.cron("#{field.name} must be #{field.min}-#{field.max}, got #{text}")
328
231
  end
329
232
 
330
- raise HronError.cron("DOM must be * when using # for nth weekday") if dom_field != "*" && dom_field != "?"
331
-
332
- minute = parse_single_value(minute_field, "minute", 0, 59)
333
- hour = parse_single_value(hour_field, "hour", 0, 23)
334
-
335
- return ScheduleData.new(
336
- expr: MonthRepeat.new(1, OrdinalWeekdayTarget.new(ordinal, weekday), [TimeOfDay.new(hour, minute)]),
337
- during: during
338
- )
233
+ value
339
234
  end
340
235
 
341
- # Check for nL pattern (last weekday of month, e.g., 5L = last Friday)
342
- if dow_field.end_with?("L") && dow_field.length > 1
343
- dow_str = dow_field[0..-2]
344
- dow_num = parse_dow_value(dow_str)
345
- weekday = cron_dow_to_weekday(dow_num)
346
-
347
- raise HronError.cron("DOM must be * when using nL for last weekday") if dom_field != "*" && dom_field != "?"
348
-
349
- minute = parse_single_value(minute_field, "minute", 0, 59)
350
- hour = parse_single_value(hour_field, "hour", 0, 23)
351
-
352
- return ScheduleData.new(
353
- expr: MonthRepeat.new(1, OrdinalWeekdayTarget.new(OrdinalPosition::LAST, weekday), [TimeOfDay.new(hour, minute)]),
354
- during: during
355
- )
236
+ def day_expression(month_days, week_days)
237
+ case [month_days, week_days]
238
+ in [:any, :any]
239
+ {of_week: DayFilterEvery.new}
240
+ in [:any, {days:}]
241
+ {of_week: weekday_filter(days)}
242
+ in [:any, {nth:, weekday:}]
243
+ {of_month: OrdinalWeekdayTarget.new(ORDINALS[nth - 1], weekday)}
244
+ in [:any, {last:}]
245
+ {of_month: OrdinalWeekdayTarget.new(OrdinalPosition::LAST, last)}
246
+ in [{days:}, :any] if days.length == 31
247
+ {of_week: DayFilterEvery.new}
248
+ in [{days:}, :any]
249
+ specs = runs(days.sort).map { |first, last| (first == last) ? SingleDay.new(first) : DayRange.new(first, last) }
250
+ {of_month: DaysTarget.new(specs)}
251
+ in [:last, :any]
252
+ {of_month: LastDayTarget.new}
253
+ in [:last_weekday, :any]
254
+ {of_month: LastWeekdayTarget.new}
255
+ in [{nearest:}, :any]
256
+ {of_month: NearestWeekdayTarget.new(nearest, nil)}
257
+ else
258
+ raise HronError.cron(BOTH_DAYS_RESTRICTED)
259
+ end
356
260
  end
357
261
 
358
- nil
359
- end
360
-
361
- # Try to parse L (last day) or LW (last weekday) patterns.
362
- def self.try_parse_last_day(minute_field, hour_field, dom_field, dow_field, during)
363
- return nil unless dom_field == "L" || dom_field == "LW"
364
-
365
- raise HronError.cron("DOW must be * when using L or LW in DOM") if dow_field != "*" && dow_field != "?"
366
-
367
- minute = parse_single_value(minute_field, "minute", 0, 59)
368
- hour = parse_single_value(hour_field, "hour", 0, 23)
369
-
370
- target = (dom_field == "LW") ? LastWeekdayTarget.new : LastDayTarget.new
371
-
372
- ScheduleData.new(
373
- expr: MonthRepeat.new(1, target, [TimeOfDay.new(hour, minute)]),
374
- during: during
375
- )
376
- end
377
-
378
- # Try to parse W (nearest weekday) patterns: 15W, 1W, etc.
379
- def self.try_parse_nearest_weekday(minute_field, hour_field, dom_field, dow_field, during)
380
- return nil unless dom_field.end_with?("W") && dom_field != "LW"
262
+ def weekday_filter(days)
263
+ case days.sort
264
+ in [0, 1, 2, 3, 4, 5, 6] then DayFilterEvery.new
265
+ in [1, 2, 3, 4, 5] then DayFilterWeekday.new
266
+ in [0, 6] then DayFilterWeekend.new
267
+ else DayFilterDays.new(days.map { |d| WEEKDAYS[d] })
268
+ end
269
+ end
381
270
 
382
- raise HronError.cron("DOW must be * when using W in DOM") if dow_field != "*" && dow_field != "?"
271
+ def equal_gap(times)
272
+ return nil if times.length < 2
383
273
 
384
- day_str = dom_field[0..-2]
385
- day = begin
386
- Integer(day_str)
387
- rescue
388
- raise(HronError.cron("invalid W day: #{day_str}"))
274
+ minutes = times.map { |t| minute_of_day(t) }
275
+ gap = minutes[1] - minutes[0]
276
+ equal = minutes.length >= 3 && minutes.each_cons(2).all? { |a, b| b - a == gap }
277
+ equal ? gap : nil
389
278
  end
390
- raise HronError.cron("W day must be 1-31, got #{day}") if day < 1 || day > 31
391
279
 
392
- minute = parse_single_value(minute_field, "minute", 0, 59)
393
- hour = parse_single_value(hour_field, "hour", 0, 23)
280
+ def interval(times, gap, days)
281
+ from = times.first
282
+ last = times.last
283
+ to = (from == MIDNIGHT && minute_of_day(last) + gap >= MINUTES_PER_DAY) ? END_OF_DAY : last
284
+ interval, unit = (gap % 60).zero? ? [gap / 60, IntervalUnit::HOURS] : [gap, IntervalUnit::MIN]
285
+ IntervalRepeat.new(interval, unit, from, to, days.is_a?(DayFilterEvery) ? nil : days)
286
+ end
394
287
 
395
- target = NearestWeekdayTarget.new(day, nil)
288
+ def too_many_times(count, gap)
289
+ return HronError.cron(INTERVAL_DAYS) if gap
396
290
 
397
- ScheduleData.new(
398
- expr: MonthRepeat.new(1, target, [TimeOfDay.new(hour, minute)]),
399
- during: during
400
- )
401
- end
291
+ HronError.cron("not expressible in hron: #{count} times a day are too many to list")
292
+ end
402
293
 
403
- # Try to parse interval patterns: */N, range/N in minute or hour fields.
404
- def self.try_parse_interval(minute_field, hour_field, dom_field, dow_field, during)
405
- # Minute interval: */N or range/N
406
- if minute_field.include?("/")
407
- range_part, step_str = minute_field.split("/", 2)
408
- interval = begin
409
- Integer(step_str)
410
- rescue
411
- raise(HronError.cron("invalid minute interval value"))
412
- end
413
- raise HronError.cron("step cannot be 0") if interval == 0
414
-
415
- if range_part == "*"
416
- from_minute = 0
417
- to_minute = 59
418
- elsif range_part.include?("-")
419
- s, e = range_part.split("-", 2)
420
- from_minute = begin
421
- Integer(s)
422
- rescue
423
- raise(HronError.cron("invalid minute range"))
424
- end
425
- to_minute = begin
426
- Integer(e)
427
- rescue
428
- raise(HronError.cron("invalid minute range"))
429
- end
430
- raise HronError.cron("range start must be <= end: #{s}-#{e}") if from_minute > to_minute
294
+ def year_target(days, months)
295
+ target = days[:of_month]
296
+ return nil unless target && months.length == 1
297
+
298
+ month = MonthName::ALL[months[0] - 1]
299
+ case target
300
+ in DaysTarget(specs: [SingleDay(day:)]) if day <= MonthName.max_day(month)
301
+ YearDateTarget.new(month, day)
302
+ in LastWeekdayTarget
303
+ YearLastWeekdayTarget.new(month)
304
+ in OrdinalWeekdayTarget(ordinal:, weekday:)
305
+ YearOrdinalWeekdayTarget.new(ordinal, weekday, month)
431
306
  else
432
- # Single value with step (e.g., 0/15) - treat as starting point
433
- from_minute = begin
434
- Integer(range_part)
435
- rescue
436
- raise(HronError.cron("invalid minute value"))
437
- end
438
- to_minute = 59
307
+ nil
439
308
  end
309
+ end
440
310
 
441
- # Determine the hour window
442
- if hour_field == "*"
443
- from_hour = 0
444
- to_hour = 23
445
- elsif hour_field.include?("-")
446
- s, e = hour_field.split("-", 2)
447
- from_hour = begin
448
- Integer(s)
449
- rescue
450
- raise(HronError.cron("invalid hour range"))
451
- end
452
- to_hour = begin
453
- Integer(e)
454
- rescue
455
- raise(HronError.cron("invalid hour range"))
456
- end
457
- elsif hour_field.include?("/")
458
- # Hour also has step - this is complex, handle as hour interval
459
- return nil
460
- else
461
- h = begin
462
- Integer(hour_field)
463
- rescue
464
- raise(HronError.cron("invalid hour"))
465
- end
466
- from_hour = h
467
- to_hour = h
468
- end
311
+ def not_expressible(reason)
312
+ HronError.cron("not expressible as cron: #{reason}")
313
+ end
469
314
 
470
- # Check if this should be a day filter
471
- day_filter = (dow_field == "*") ? nil : parse_cron_dow(dow_field)
472
-
473
- if dom_field == "*" || dom_field == "?"
474
- # Determine the end minute based on context
475
- end_minute = if from_minute == 0 && to_minute == 59 && to_hour == 23
476
- # Full day: 00:00 to 23:59
477
- 59
478
- elsif from_minute == 0 && to_minute == 59
479
- # Partial day with full minutes range: use :00 for cleaner output
480
- 0
481
- else
482
- to_minute
483
- end
315
+ def repeats_once(interval, unit)
316
+ raise not_expressible("multi-#{unit} repeats not supported") if interval > 1
317
+ end
484
318
 
485
- return ScheduleData.new(
486
- expr: IntervalRepeat.new(
487
- interval,
488
- IntervalUnit::MIN,
489
- TimeOfDay.new(from_hour, from_minute),
490
- TimeOfDay.new(to_hour, end_minute),
491
- day_filter
492
- ),
493
- during: during
494
- )
319
+ def day_fields(expr)
320
+ case expr
321
+ when IntervalRepeat
322
+ ["*", expr.day_filter ? filter_field(expr.day_filter) : "*"]
323
+ when DayRepeat
324
+ repeats_once(expr.interval, "day")
325
+ ["*", filter_field(expr.days)]
326
+ when WeekRepeat
327
+ repeats_once(expr.interval, "week")
328
+ ["*", weekdays_field(expr.days)]
329
+ when MonthRepeat
330
+ repeats_once(expr.interval, "month")
331
+ month_target_fields(expr.target)
332
+ when YearRepeat
333
+ repeats_once(expr.interval, "year")
334
+ year_target_fields(expr.target)
335
+ when SingleDateExpr
336
+ raise not_expressible("ISO dates do not repeat") if expr.date.is_a?(IsoDate)
337
+
338
+ [expr.date.day.to_s, "*"]
495
339
  end
496
340
  end
497
341
 
498
- # Hour interval: 0 */N or 0 range/N
499
- if hour_field.include?("/") && (minute_field == "0" || minute_field == "00")
500
- range_part, step_str = hour_field.split("/", 2)
501
- interval = begin
502
- Integer(step_str)
503
- rescue
504
- raise(HronError.cron("invalid hour interval value"))
505
- end
506
- raise HronError.cron("step cannot be 0") if interval == 0
507
-
508
- if range_part == "*"
509
- from_hour = 0
510
- to_hour = 23
511
- elsif range_part.include?("-")
512
- s, e = range_part.split("-", 2)
513
- from_hour = begin
514
- Integer(s)
515
- rescue
516
- raise(HronError.cron("invalid hour range"))
517
- end
518
- to_hour = begin
519
- Integer(e)
520
- rescue
521
- raise(HronError.cron("invalid hour range"))
522
- end
523
- raise HronError.cron("range start must be <= end: #{s}-#{e}") if from_hour > to_hour
524
- else
525
- from_hour = begin
526
- Integer(range_part)
527
- rescue
528
- raise(HronError.cron("invalid hour value"))
529
- end
530
- to_hour = 23
531
- end
342
+ def month_target_fields(target)
343
+ case target
344
+ when DaysTarget
345
+ [list_field(Evaluator.days_of(target).sort.uniq, 31), "*"]
346
+ when LastDayTarget
347
+ ["L", "*"]
348
+ when LastWeekdayTarget
349
+ ["LW", "*"]
350
+ when NearestWeekdayTarget
351
+ raise not_expressible("directional nearest weekday not supported") if target.direction
532
352
 
533
- if (dom_field == "*" || dom_field == "?") && (dow_field == "*" || dow_field == "?")
534
- # Use :59 only for full day (00:00 to 23:59), otherwise use :00
535
- end_minute = (from_hour == 0 && to_hour == 23) ? 59 : 0
536
-
537
- return ScheduleData.new(
538
- expr: IntervalRepeat.new(
539
- interval,
540
- IntervalUnit::HOURS,
541
- TimeOfDay.new(from_hour, 0),
542
- TimeOfDay.new(to_hour, end_minute),
543
- nil
544
- ),
545
- during: during
546
- )
353
+ ["#{target.day}W", "*"]
354
+ when OrdinalWeekdayTarget
355
+ ["*", ordinal_field(target.ordinal, target.weekday)]
547
356
  end
548
357
  end
549
358
 
550
- nil
551
- end
552
-
553
- # Parse a DOM field into a MonthTarget.
554
- def self.parse_dom_field(field)
555
- specs = []
556
-
557
- field.split(",").each do |part|
558
- if part.include?("/")
559
- # Step value: 1-31/2 or */5
560
- range_part, step_str = part.split("/", 2)
561
- if range_part == "*"
562
- start_day = 1
563
- end_day = 31
564
- elsif range_part.include?("-")
565
- s, e = range_part.split("-", 2)
566
- start_day = begin
567
- Integer(s)
568
- rescue
569
- raise(HronError.cron("invalid DOM range start: #{s}"))
570
- end
571
- end_day = begin
572
- Integer(e)
573
- rescue
574
- raise(HronError.cron("invalid DOM range end: #{e}"))
575
- end
576
- raise HronError.cron("range start must be <= end: #{start_day}-#{end_day}") if start_day > end_day
577
- else
578
- start_day = begin
579
- Integer(range_part)
580
- rescue
581
- raise(HronError.cron("invalid DOM value: #{range_part}"))
582
- end
583
- end_day = 31
584
- end
585
-
586
- step = begin
587
- Integer(step_str)
588
- rescue
589
- raise(HronError.cron("invalid DOM step: #{step_str}"))
590
- end
591
- raise HronError.cron("step cannot be 0") if step == 0
359
+ def year_target_fields(target)
360
+ case target
361
+ when YearDateTarget, YearDayOfMonthTarget
362
+ [target.day.to_s, "*"]
363
+ when YearOrdinalWeekdayTarget
364
+ ["*", ordinal_field(target.ordinal, target.weekday)]
365
+ when YearLastWeekdayTarget
366
+ ["LW", "*"]
367
+ end
368
+ end
592
369
 
593
- validate_dom(start_day)
594
- validate_dom(end_day)
370
+ def month_field(schedule)
371
+ during = schedule.during
372
+ month = own_month(schedule.expression)
373
+ if month
374
+ raise not_expressible("during excludes the schedule's month") unless during.empty? || during.include?(month)
595
375
 
596
- d = start_day
597
- while d <= end_day
598
- specs << SingleDay.new(d)
599
- d += step
600
- end
601
- elsif part.include?("-")
602
- # Range: 1-5
603
- start_str, end_str = part.split("-", 2)
604
- start_day = begin
605
- Integer(start_str)
606
- rescue
607
- raise(HronError.cron("invalid DOM range start: #{start_str}"))
608
- end
609
- end_day = begin
610
- Integer(end_str)
611
- rescue
612
- raise(HronError.cron("invalid DOM range end: #{end_str}"))
613
- end
614
- raise HronError.cron("range start must be <= end: #{start_day}-#{end_day}") if start_day > end_day
615
- validate_dom(start_day)
616
- validate_dom(end_day)
617
- specs << DayRange.new(start_day, end_day)
376
+ MonthName.number(month).to_s
377
+ elsif during.empty?
378
+ "*"
618
379
  else
619
- # Single: 15
620
- day = begin
621
- Integer(part)
622
- rescue
623
- raise(HronError.cron("invalid DOM value: #{part}"))
624
- end
625
- validate_dom(day)
626
- specs << SingleDay.new(day)
380
+ list_field(during.map { |m| MonthName.number(m) }.sort.uniq, 12)
627
381
  end
628
382
  end
629
383
 
630
- DaysTarget.new(specs)
631
- end
632
-
633
- def self.validate_dom(day)
634
- raise HronError.cron("DOM must be 1-31, got #{day}") if day < 1 || day > 31
635
- end
384
+ def own_month(expr)
385
+ case expr
386
+ when YearRepeat then expr.target.month
387
+ when SingleDateExpr then expr.date.is_a?(NamedDate) ? expr.date.month : nil
388
+ end
389
+ end
636
390
 
637
- # Parse a DOW field into a DayFilter.
638
- def self.parse_cron_dow(field)
639
- return DayFilterEvery.new if field == "*"
640
-
641
- days = []
642
-
643
- field.split(",").each do |part|
644
- if part.include?("/")
645
- # Step value: 0-6/2 or */2
646
- range_part, step_str = part.split("/", 2)
647
- if range_part == "*"
648
- start_dow = 0
649
- end_dow = 6
650
- elsif range_part.include?("-")
651
- s, e = range_part.split("-", 2)
652
- start_dow = parse_dow_value_raw(s)
653
- end_dow = parse_dow_value_raw(e)
654
- raise HronError.cron("range start must be <= end: #{s}-#{e}") if start_dow > end_dow
655
- else
656
- start_dow = parse_dow_value_raw(range_part)
657
- end_dow = 6
658
- end
391
+ def time_fields(expr)
392
+ times = daily_times(expr)
393
+ minutes = times.map { |t| t % 60 }.sort.uniq
394
+ hours = times.map { |t| t / 60 }.sort.uniq
395
+ if minutes.length * hours.length != times.length
396
+ raise not_expressible("times are not every combination of their minutes and hours")
397
+ end
659
398
 
660
- step = begin
661
- Integer(step_str)
662
- rescue
663
- raise(HronError.cron("invalid DOW step: #{step_str}"))
664
- end
665
- raise HronError.cron("step cannot be 0") if step == 0
399
+ [step_field(minutes, 60), step_field(hours, 24)]
400
+ end
666
401
 
667
- d = start_dow
668
- while d <= end_dow
669
- days << cron_dow_to_weekday(d)
670
- d += step
671
- end
672
- elsif part.include?("-")
673
- # Range: 1-5 or MON-FRI
674
- # Parse without normalizing 7 to 0 for range purposes
675
- start_str, end_str = part.split("-", 2)
676
- start_dow = parse_dow_value_raw(start_str)
677
- end_dow = parse_dow_value_raw(end_str)
678
- raise HronError.cron("range start must be <= end: #{start_str}-#{end_str}") if start_dow > end_dow
679
-
680
- (start_dow..end_dow).each do |d|
681
- # Normalize 7 to 0 (Sunday) when converting to weekday
682
- normalized = (d == 7) ? 0 : d
683
- days << cron_dow_to_weekday(normalized)
684
- end
402
+ def daily_times(expr)
403
+ times = if expr.is_a?(IntervalRepeat)
404
+ Evaluator.interval_slots(expr.interval, expr.unit, expr.from_time, expr.to_time)
685
405
  else
686
- # Single: 1 or MON
687
- dow = parse_dow_value(part)
688
- days << cron_dow_to_weekday(dow)
406
+ expr.times.map { |t| minute_of_day(t) }
689
407
  end
408
+ times.sort.uniq
690
409
  end
691
410
 
692
- # Check for special patterns
693
- if days.length == 5
694
- sorted = days.sort_by { |d| Weekday.number(d) }
695
- return DayFilterWeekday.new if sorted == Weekday::WEEKDAYS
696
- end
697
- if days.length == 2
698
- sorted = days.sort_by { |d| Weekday.number(d) }
699
- return DayFilterWeekend.new if sorted == Weekday::WEEKEND
411
+ def filter_field(filter)
412
+ case filter
413
+ when DayFilterEvery then "*"
414
+ when DayFilterWeekday then weekdays_field(Weekday::WEEKDAYS)
415
+ when DayFilterWeekend then weekdays_field(Weekday::WEEKEND)
416
+ when DayFilterDays then weekdays_field(filter.days)
417
+ end
700
418
  end
701
419
 
702
- DayFilterDays.new(days)
703
- end
704
-
705
- # Parse a DOW value (number 0-7 or name SUN-SAT), normalizing 7 to 0.
706
- def self.parse_dow_value(s)
707
- raw = parse_dow_value_raw(s)
708
- # Normalize 7 to 0 (both mean Sunday)
709
- (raw == 7) ? 0 : raw
710
- end
711
-
712
- # Parse a DOW value without normalizing 7 to 0 (for range checking).
713
- def self.parse_dow_value_raw(s)
714
- # Try as number first
715
- if /^\d+$/.match?(s)
716
- n = Integer(s)
717
- raise HronError.cron("DOW must be 0-7, got #{n}") if n > 7
420
+ def weekdays_field(days)
421
+ list_field(days.map { |d| Weekday.cron_dow(d) }.sort.uniq, 7)
422
+ end
718
423
 
719
- return n
424
+ def ordinal_field(ordinal, weekday)
425
+ day = Weekday.cron_dow(weekday)
426
+ (ordinal == OrdinalPosition::LAST) ? "#{day}L" : "#{day}##{OrdinalPosition.to_n(ordinal)}"
720
427
  end
721
- # Try as name
722
- n = DOW_NAME_MAP[s.upcase]
723
- raise HronError.cron("invalid DOW: #{s}") unless n
724
428
 
725
- n
726
- end
429
+ def step_field(values, size)
430
+ first = values.first
431
+ last = values.last
432
+ gap = (values.length > 1) ? values[1] - first : nil
433
+ equal_gaps = !gap.nil? && values.each_cons(2).all? { |a, b| b - a == gap }
434
+ if values.length == size
435
+ "*"
436
+ elsif gap.nil?
437
+ first.to_s
438
+ elsif equal_gaps && first.zero? && last + gap == size
439
+ "*/#{gap}"
440
+ elsif equal_gaps && gap == 1
441
+ "#{first}-#{last}"
442
+ elsif equal_gaps && values.length >= 3
443
+ "#{first}-#{last}/#{gap}"
444
+ else
445
+ list_field(values, size)
446
+ end
447
+ end
727
448
 
728
- def self.cron_dow_to_weekday(n)
729
- result = CRON_DOW_MAP[n]
730
- raise HronError.cron("invalid DOW number: #{n}") unless result
449
+ def list_field(values, size)
450
+ return "*" if values.length == size
731
451
 
732
- result
733
- end
452
+ runs(values).map { |first, last| (first == last) ? first.to_s : "#{first}-#{last}" }.join(",")
453
+ end
734
454
 
735
- # Parse a single numeric value with validation.
736
- def self.parse_single_value(field, name, min, max)
737
- value = begin
738
- Integer(field)
739
- rescue
740
- raise(HronError.cron("invalid #{name} field: #{field}"))
455
+ def runs(sorted_values)
456
+ sorted_values.slice_when { |a, b| b != a + 1 }.map { |run| [run.first, run.last] }
741
457
  end
742
- raise HronError.cron("#{name} must be #{min}-#{max}, got #{value}") if value < min || value > max
743
458
 
744
- value
459
+ def minute_of_day(time)
460
+ time.hour * 60 + time.minute
461
+ end
745
462
  end
746
463
  end
747
464
  end