hron 1.0.0 → 2.1.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.
@@ -5,1146 +5,475 @@ require "time"
5
5
  require "tzinfo"
6
6
  require_relative "ast"
7
7
  require_relative "error"
8
+ require_relative "eval/calendar"
9
+ require_relative "eval/wall_clock"
8
10
 
9
11
  module Hron
10
- # =============================================================================
11
- # Iteration Safety Limits
12
- # =============================================================================
13
- # MAX_ITERATIONS (1000): Maximum iterations for next_from/previous_from loops.
14
- # Prevents infinite loops when searching for valid occurrences.
15
- #
16
- # Expression-specific limits:
17
- # - Day repeat: 8 days (covers one week + margin)
18
- # - Week repeat: 54 weeks (covers one year + margin)
19
- # - Month repeat: 24 * interval months (covers 2 years scaled by interval)
20
- # - Year repeat: 8 * interval years (covers reasonable future horizon)
21
- #
22
- # These limits are generous safety bounds. In practice, valid schedules
23
- # find occurrences within the first few iterations.
24
- # =============================================================================
25
-
26
- # =============================================================================
27
- # DST (Daylight Saving Time) Handling
28
- # =============================================================================
29
- # When resolving a wall-clock time to an instant:
30
- #
31
- # 1. DST Gap (Spring Forward):
32
- # - Time doesn't exist (e.g., 2:30 AM during spring forward)
33
- # - Solution: Push forward to the next valid time after the gap
34
- # - Example: 2:30 AM -> 3:00 AM (or 3:30 AM depending on gap size)
35
- #
36
- # 2. DST Fold (Fall Back):
37
- # - Time is ambiguous (e.g., 1:30 AM occurs twice)
38
- # - Solution: Use first occurrence (fold=0 / pre-transition time)
39
- # - This matches user expectation for scheduling
40
- #
41
- # All implementations use the same algorithm for cross-language consistency.
42
- # =============================================================================
43
-
44
- # =============================================================================
45
- # Interval Alignment (Anchor Date)
46
- # =============================================================================
47
- # For schedules with interval > 1 (e.g., "every 3 days"), we need to
48
- # determine which dates are valid based on alignment with an anchor.
49
- #
50
- # Formula: (date_offset - anchor_offset) mod interval == 0
51
- #
52
- # Where:
53
- # - date_offset: days/weeks/months from epoch to candidate date
54
- # - anchor_offset: days/weeks/months from epoch to anchor date
55
- # - interval: the repeat interval (e.g., 3 for "every 3 days")
56
- #
57
- # Default anchor: Epoch (1970-01-01)
58
- # Custom anchor: Set via "starting YYYY-MM-DD" clause
59
- #
60
- # For week repeats, we use epoch Monday (1970-01-05) as the reference
61
- # point to align week boundaries correctly.
62
- # =============================================================================
63
-
64
- EPOCH_DATE = Date.new(1970, 1, 1)
65
- EPOCH_MONDAY = Date.new(1970, 1, 5)
66
-
67
- # Timezone resolution
68
- module TzResolver
69
- def self.resolve(tz_name)
70
- if tz_name && !tz_name.empty?
71
- TZInfo::Timezone.get(tz_name)
72
- else
73
- # Default to UTC for deterministic, portable behavior
74
- TZInfo::Timezone.get("UTC")
75
- end
76
- end
77
- end
12
+ class Evaluator
13
+ # Default anchor for week intervals (spec/README.md, "WeekRepeat epoch alignment").
14
+ EPOCH_MONDAY = Date.new(1970, 1, 5, Date::GREGORIAN)
78
15
 
79
- # Evaluator helpers
80
- module EvalHelpers
81
- def self.at_time_on_date(d, tod, tz)
82
- # Create local time representation (using UTC to avoid system TZ interference)
83
- local_time = Time.utc(d.year, d.month, d.day, tod.hour, tod.minute, 0)
84
-
85
- # Get periods for this local time
86
- periods = tz.periods_for_local(local_time)
87
-
88
- case periods.length
89
- when 0
90
- # Time doesn't exist (spring forward gap)
91
- # Push the time forward past the gap (like "compatible" disambiguation)
92
- # For example: 02:30 during DST spring forward -> 03:30
93
-
94
- # Find the transition on this date
95
- day_start = Time.utc(d.year, d.month, d.day, 0, 0, 0)
96
- day_end = Time.utc(d.year, d.month, d.day, 23, 59, 59)
97
- transitions = tz.transitions_up_to(day_end, day_start)
98
-
99
- if transitions.any?
100
- # Find the spring-forward transition (where offset increases / DST starts)
101
- transition = transitions.find { |t| t.offset.dst? }
102
- if transition
103
- # The transition time is when the gap starts
104
- # We need to push the requested time forward by the gap size
105
- prev_offset = transition.previous_offset.utc_total_offset
106
- new_offset = transition.offset.utc_total_offset
107
- gap_seconds = new_offset - prev_offset # Positive for spring forward
108
-
109
- # Push the local time forward by the gap amount
110
- # E.g., 02:30 + 1 hour = 03:30 local
111
- pushed_local = local_time + gap_seconds
112
-
113
- # Convert the pushed local time to UTC using the new offset
114
- # E.g., 03:30 local EDT (-4h) -> 07:30 UTC
115
- return pushed_local - new_offset
116
- end
117
- end
16
+ EPOCH_DATE = Date.new(1970, 1, 1, Date::GREGORIAN)
118
17
 
119
- # Fallback: try to construct the time and let TZInfo handle it
120
- # This shouldn't normally be reached
121
- begin
122
- tz.local_to_utc(local_time)
123
- rescue TZInfo::AmbiguousTime, TZInfo::PeriodNotFound
124
- nil
125
- end
126
- when 1
127
- # Unambiguous time - straightforward conversion
128
- period = periods[0]
129
- utc_offset = period.offset.utc_total_offset
130
- local_time - utc_offset
131
- when 2
132
- # Ambiguous time (fall back) - use first occurrence (pre-transition)
133
- # Period 0 is the earlier offset (e.g., EDT -04:00 before fall back)
134
- period = periods[0]
135
- utc_offset = period.offset.utc_total_offset
136
- local_time - utc_offset
137
- end
138
- end
18
+ # spec/README.md, "Supported range".
19
+ SUPPORTED_RANGE = Time.utc(1, 1, 2)...Time.utc(9999, 12, 30)
139
20
 
140
- def self.matches_day_filter(d, filter)
141
- dow = d.cwday # Monday=1 ... Sunday=7
142
- case filter
143
- when DayFilterEvery
144
- true
145
- when DayFilterWeekday
146
- dow.between?(1, 5)
147
- when DayFilterWeekend
148
- [6, 7].include?(dow)
149
- when DayFilterDays
150
- filter.days.any? { |wd| Weekday.number(wd) == dow }
151
- else
152
- false
153
- end
154
- end
21
+ # Slack beyond the horizon for the period one behind the first date's, where a
22
+ # search starts, and for a horizon that starts mid-period.
23
+ HORIZON_MARGIN_PERIODS = 2
155
24
 
156
- def self.last_day_of_month(year, month)
157
- Date.new(year, month, -1)
158
- end
25
+ # How many dates past its scheduled date a fixed time can land: one shifted out of a gap
26
+ # before midnight lands on the next date.
27
+ MAX_SHIFT_DAYS = 1
159
28
 
160
- def self.last_weekday_of_month(year, month)
161
- d = last_day_of_month(year, month)
162
- d -= 1 while d.cwday >= 6
163
- d
164
- end
29
+ # How many dates behind a date that has begun now's wall date can read: from the second
30
+ # pass of a fall-back overlap that crosses midnight, one.
31
+ MAX_OVERLAP_DAYS = 1
165
32
 
166
- # Get the nearest weekday to a given day in a month.
167
- # - direction=nil: standard cron W behavior (never crosses month boundary)
168
- # - direction=NearestDirection::NEXT: always prefer following weekday (can cross to next month)
169
- # - direction=NearestDirection::PREVIOUS: always prefer preceding weekday (can cross to prev month)
170
- # Returns nil if the target_day doesn't exist in the month (e.g., day 31 in February).
171
- def self.nearest_weekday(year, month, target_day, direction)
172
- last = last_day_of_month(year, month)
173
- last_day_num = last.day
174
-
175
- # If target day doesn't exist in this month, return nil (skip this month)
176
- return nil if target_day > last_day_num
177
-
178
- date = Date.new(year, month, target_day)
179
- dow = date.cwday # Monday=1 ... Sunday=7
180
-
181
- # Already a weekday (Mon-Fri = 1-5)
182
- return date if dow.between?(1, 5)
183
-
184
- case direction
185
- when nil
186
- # Standard cron W behavior: never cross month boundary
187
- if dow == 6 && target_day == 1
188
- # Saturday at 1st: can't go to previous month, use Monday (day 3)
189
- date + 2
190
- elsif dow == 6
191
- # Saturday: Friday
192
- date - 1
193
- elsif target_day >= last_day_num
194
- # Sunday at end of month: can't go to next month, use Friday
195
- date - 2
196
- else
197
- # Sunday: Monday
198
- date + 1
199
- end
33
+ # Feb 29 can be eight years away, as from 2096-03-01 to 2104-02-29.
34
+ NAMED_UNTIL_MAX_YEARS = 8
200
35
 
201
- when NearestDirection::NEXT
202
- # Always prefer following weekday (can cross month)
203
- if dow == 6 # Saturday -> Monday
204
- date + 2
205
- else # Sunday -> Monday
206
- date + 1
207
- end
36
+ # interval_slots and days_of are shared with Cron.to_cron, so conversion never disagrees with
37
+ # evaluation.
38
+ def self.interval_slots(interval, unit, from, to)
39
+ step = (unit == IntervalUnit::MIN) ? interval : interval * WallClock::MINUTES_PER_HOUR
40
+ (WallClock.minute_of_day(from)..WallClock.minute_of_day(to)).step(step).to_a
41
+ end
208
42
 
209
- when NearestDirection::PREVIOUS
210
- # Always prefer preceding weekday (can cross month if day==1)
211
- if dow == 6 # Saturday -> Friday
212
- date - 1
213
- else # Sunday -> Friday (go back 2 days)
214
- date - 2
43
+ def self.days_of(target)
44
+ target.specs.flat_map do |spec|
45
+ case spec
46
+ when SingleDay then [spec.day]
47
+ when DayRange then (spec.start..spec.end_day).to_a
215
48
  end
216
49
  end
217
50
  end
218
51
 
219
- def self.nth_weekday_of_month(year, month, weekday, n)
220
- target_dow = Weekday.number(weekday)
221
- d = Date.new(year, month, 1)
222
- d += 1 while d.cwday != target_dow
223
- (n - 1).times { d += 7 }
224
- return nil if d.month != month
225
-
226
- d
227
- end
228
-
229
- def self.last_weekday_in_month(year, month, weekday)
230
- target_dow = Weekday.number(weekday)
231
- d = last_day_of_month(year, month)
232
- d -= 1 while d.cwday != target_dow
233
- d
52
+ def self.next_from(schedule, now)
53
+ require_time(now, "now")
54
+ Search.new(schedule).nearest(now, Direction::FORWARD) if SUPPORTED_RANGE.cover?(now)
234
55
  end
235
56
 
236
- def self.weeks_between(a, b)
237
- (b - a).to_i / 7
57
+ def self.previous_from(schedule, now)
58
+ require_time(now, "now")
59
+ Search.new(schedule).nearest(now, Direction::BACKWARD) if SUPPORTED_RANGE.cover?(now)
238
60
  end
239
61
 
240
- def self.days_between(a, b)
241
- (b - a).to_i
242
- end
62
+ # Not Enumerable#first, which raises RangeError for an n past a machine integer, where
63
+ # n only caps the count (spec/README.md, "Timestamps and counts").
64
+ def self.next_n_from(schedule, now, n)
65
+ require_time(now, "now")
66
+ raise TypeError, "n must be an Integer, not #{n.class}" unless n.is_a?(Integer)
243
67
 
244
- def self.months_between_ym(a, b)
245
- (b.year * 12) + b.month - ((a.year * 12) + a.month)
246
- end
68
+ results = []
69
+ return results unless n.positive?
247
70
 
248
- def self.is_excepted(d, exceptions)
249
- exceptions.any? do |exc|
250
- case exc
251
- when NamedException
252
- d.month == MonthName.number(exc.month) && d.day == exc.day
253
- when IsoException
254
- d == Date.parse(exc.date)
255
- else
256
- false
257
- end
71
+ occurrences(schedule, now).each do |occurrence|
72
+ results << occurrence
73
+ break if results.size == n
258
74
  end
75
+ results
259
76
  end
260
77
 
261
- def self.matches_during(d, during)
262
- return true if during.empty?
78
+ def self.occurrences(schedule, from)
79
+ require_time(from, "from")
80
+ Enumerator.new do |yielder|
81
+ next unless SUPPORTED_RANGE.cover?(from)
263
82
 
264
- during.any? { |mn| MonthName.number(mn) == d.month }
83
+ search = Search.new(schedule)
84
+ current = from
85
+ while (current = search.nearest(current, Direction::FORWARD))
86
+ yielder << current
87
+ end
88
+ end.lazy
265
89
  end
266
90
 
267
- def self.next_during_month(d, during)
268
- months = during.map { |mn| MonthName.number(mn) }.sort
269
-
270
- months.each do |m|
271
- return Date.new(d.year, m, 1) if m > d.month
272
- end
91
+ def self.between(schedule, from, to)
92
+ require_time(from, "from")
93
+ require_time(to, "to")
94
+ Enumerator.new do |yielder|
95
+ next unless SUPPORTED_RANGE.cover?(to)
273
96
 
274
- # Wrap to first month of next year
275
- Date.new(d.year + 1, months[0], 1)
276
- end
97
+ occurrences(schedule, from).each do |instant|
98
+ break if instant > to
277
99
 
278
- def self.resolve_until(until_spec, now)
279
- case until_spec
280
- when IsoUntil
281
- Date.parse(until_spec.date)
282
- when NamedUntil
283
- year = now.year
284
- [year, year + 1].each do |y|
285
- d = Date.new(y, MonthName.number(until_spec.month), until_spec.day)
286
- return d if d >= now.to_date
287
- rescue ArgumentError
288
- next
100
+ yielder << instant
289
101
  end
290
- Date.new(year + 1, MonthName.number(until_spec.month), until_spec.day)
291
- end
102
+ end.lazy
292
103
  end
293
104
 
294
- def self.earliest_future_at_times(d, times, tz, now)
295
- best = nil
296
- times.each do |tod|
297
- candidate = at_time_on_date(d, tod, tz)
298
- next unless candidate
105
+ # Defined through the forward search, so the two cannot disagree (spec/README.md,
106
+ # "matches is true exactly when the minute containing t is an occurrence").
107
+ def self.matches(schedule, dt)
108
+ require_time(dt, "datetime")
109
+ return false unless SUPPORTED_RANGE.cover?(dt)
299
110
 
300
- best = candidate if candidate > now && (best.nil? || candidate < best)
301
- end
302
- best
111
+ search = Search.new(schedule)
112
+ local = search.zone.to_local(dt)
113
+ minute = dt - local.sec - local.subsec
114
+ # Occurrences fall on whole seconds, so none lies between this and the minute.
115
+ just_before = minute - 1
116
+ # An occurrence never lands before the date it is scheduled on, so one at this minute
117
+ # is scheduled on or before the minute's wall date.
118
+ search.clauses.end_on(local.to_date.gregorian)
119
+ search.nearest(just_before, Direction::FORWARD) == minute
303
120
  end
304
121
 
305
- def self.latest_past_at_times(d, times, tz, now)
306
- best = nil
307
- times.each do |tod|
308
- candidate = at_time_on_date(d, tod, tz)
309
- next unless candidate
310
-
311
- best = candidate if candidate < now && (best.nil? || candidate > best)
312
- end
313
- best
122
+ # A usage error, never a HronError (spec/README.md, "Timestamps and counts").
123
+ def self.require_time(value, name)
124
+ raise TypeError, "#{name} must be a Time, not #{value.class}" unless value.is_a?(Time)
314
125
  end
126
+ private_class_method :require_time
315
127
 
316
- def self.prev_during_month(d, during)
317
- months = during.map { |mn| MonthName.number(mn) }.sort.reverse
128
+ class Direction
129
+ attr_reader :sign
318
130
 
319
- months.each do |m|
320
- return last_day_of_month(d.year, m) if m < d.month
131
+ def initialize(sign)
132
+ @sign = sign
133
+ @forward = sign.positive?
321
134
  end
322
135
 
323
- # Wrap to last month of previous year
324
- return last_day_of_month(d.year - 1, months[0]) unless months.empty?
325
-
326
- nil
327
- end
328
- end
329
-
330
- # Main evaluator class
331
- class Evaluator
332
- def self.next_from(schedule, now)
333
- tz = TzResolver.resolve(schedule.timezone)
334
- until_date = schedule.until ? EvalHelpers.resolve_until(schedule.until, now) : nil
335
- has_exceptions = !schedule.except.empty?
336
- has_during = !schedule.during.empty?
337
-
338
- # Check if expression is NearestWeekday with direction (can cross month boundaries)
339
- handles_during_internally = schedule.expr.is_a?(MonthRepeat) &&
340
- schedule.expr.target.is_a?(NearestWeekdayTarget) &&
341
- !schedule.expr.target.direction.nil?
342
-
343
- current = now
344
- 1000.times do
345
- candidate = next_expr(schedule.expr, tz, schedule.anchor, current, schedule.during)
346
- return nil unless candidate
347
-
348
- c_date = candidate.to_date
349
-
350
- # Apply until filter
351
- return nil if until_date && c_date > until_date
352
-
353
- # Apply during filter
354
- # Skip this check for expressions that handle during internally (NearestWeekday with direction)
355
- if has_during && !handles_during_internally && !EvalHelpers.matches_during(c_date, schedule.during)
356
- skip_to = EvalHelpers.next_during_month(c_date, schedule.during)
357
- midnight = EvalHelpers.at_time_on_date(skip_to, TimeOfDay.new(0, 0), tz)
358
- current = midnight - 1
359
- next
360
- end
361
-
362
- # Apply except filter
363
- if has_exceptions && EvalHelpers.is_excepted(c_date, schedule.except)
364
- next_day = c_date + 1
365
- midnight = EvalHelpers.at_time_on_date(next_day, TimeOfDay.new(0, 0), tz)
366
- current = midnight - 1
367
- next
368
- end
369
-
370
- return candidate
136
+ def forward?
137
+ @forward
371
138
  end
372
139
 
373
- nil
374
- end
375
-
376
- def self.next_n_from(schedule, now, n)
377
- results = []
378
- current = now
379
- n.times do
380
- nxt = next_from(schedule, current)
381
- break unless nxt
382
-
383
- results << nxt
384
- current = nxt + 60 # Add 1 minute
140
+ def precedes?(a, b)
141
+ @forward ? a < b : a > b
385
142
  end
386
- results
387
- end
388
143
 
389
- def self.previous_from(schedule, now)
390
- tz = TzResolver.resolve(schedule.timezone)
391
- anchor_date = schedule.anchor ? Date.parse(schedule.anchor) : nil
392
- has_exceptions = !schedule.except.empty?
393
- has_during = !schedule.during.empty?
394
-
395
- # Handle until clause - if now is after until, search from end of until date
396
- search_from = now
397
- if schedule.until
398
- until_date = EvalHelpers.resolve_until(schedule.until, now)
399
- now_date = now.to_date
400
- if now_date > until_date
401
- next_day = until_date + 1
402
- search_from = EvalHelpers.at_time_on_date(next_day, TimeOfDay.new(0, 0), tz)
403
- end
144
+ def in_order(items)
145
+ (@forward || items.size < 2) ? items : items.reverse
404
146
  end
405
147
 
406
- 1000.times do
407
- candidate = prev_expr(schedule.expr, tz, schedule.anchor, search_from)
408
- return nil unless candidate
148
+ FORWARD = new(1)
149
+ BACKWARD = new(-1)
150
+ private_class_method :new
151
+ end
409
152
 
410
- c_date = candidate.to_date
153
+ class Search
154
+ attr_reader :zone, :clauses
411
155
 
412
- # Check if before anchor
413
- return nil if anchor_date && c_date < anchor_date
156
+ def initialize(schedule)
157
+ name = schedule.timezone
158
+ @zone = TZInfo::Timezone.get(name || "UTC")
159
+ @result_zone = name ? @zone : "UTC"
160
+ @clauses = Clauses.new(schedule)
161
+ @cadence = Cadence.of(schedule.expression, @clauses.starting)
162
+ @times = DailyTimes.of(schedule.expression)
163
+ @expr = schedule.expression
164
+ end
414
165
 
415
- # Check until date - should not return occurrences after until
416
- if schedule.until
417
- until_date = EvalHelpers.resolve_until(schedule.until, now)
418
- if c_date > until_date
419
- search_from = candidate
420
- next
421
- end
422
- end
166
+ def nearest(now, direction)
167
+ now = @zone.utc_to_local(now.getutc)
168
+ now_date = now.to_date.gregorian
169
+ first_date = @clauses.clamp(now_date, direction)
170
+ # A nearest weekday or a DST shift can move an occurrence out of the period it is
171
+ # scheduled in, so the search starts one period back.
172
+ first_period = @cadence.period_of(first_date) - direction.sign
173
+ farthest = @clauses.farthest_except_date(direction)
174
+ reach = farthest ? @cadence.period_of(farthest) : first_period
175
+ shift = @times.max_shift_days
176
+ best = nil
177
+ each_candidate(first_period, reach, direction) do |candidate|
178
+ date = candidate.date
179
+ break if best && !could_beat?(date, best.landing, direction, shift)
180
+ break if @clauses.ends_search?(date, direction)
181
+ next if behind?(date, now_date, direction, shift) || !@clauses.allows?(candidate)
423
182
 
424
- # Apply except filter
425
- if has_exceptions && EvalHelpers.is_excepted(c_date, schedule.except)
426
- search_from = candidate
427
- next
183
+ occurrence = nearest_on_date(date, now, direction)
184
+ best = occurrence if occurrence && (best.nil? || direction.precedes?(occurrence.instant, best.instant))
428
185
  end
186
+ Time.at(best.instant, in: @result_zone) if best && SUPPORTED_RANGE.cover?(best.instant)
187
+ end
429
188
 
430
- # Apply during filter
431
- if has_during && !EvalHelpers.matches_during(c_date, schedule.during)
432
- prev_month = EvalHelpers.prev_during_month(c_date, schedule.during)
433
- return nil unless prev_month
189
+ private
434
190
 
435
- next_day = prev_month + 1
436
- search_from = EvalHelpers.at_time_on_date(next_day, TimeOfDay.new(0, 0), tz)
437
- next
191
+ def each_candidate(first_period, reach, direction, &)
192
+ @cadence.period_starts(first_period, reach, direction) do |start|
193
+ direction.in_order(candidates_in_period(start)).each(&) unless rejects_period?(start)
438
194
  end
439
-
440
- return candidate
441
195
  end
442
196
 
443
- nil
444
- end
445
-
446
- def self.prev_expr(expr, tz, anchor, now)
447
- case expr
448
- when DayRepeat
449
- prev_day_repeat(expr.interval, expr.days, expr.times, tz, anchor, now)
450
- when IntervalRepeat
451
- prev_interval_repeat(expr.interval, expr.unit, expr.from_time, expr.to_time, expr.day_filter, tz, now)
452
- when WeekRepeat
453
- prev_week_repeat(expr.interval, expr.days, expr.times, tz, anchor, now)
454
- when MonthRepeat
455
- prev_month_repeat(expr.interval, expr.target, expr.times, tz, anchor, now)
456
- when SingleDateExpr
457
- prev_single_date(expr.date, expr.times, tz, now)
458
- when YearRepeat
459
- prev_year_repeat(expr.interval, expr.target, expr.times, tz, anchor, now)
197
+ # A day or month period's candidates all target its own month, so one whose month
198
+ # during rejects holds nothing.
199
+ def rejects_period?(start)
200
+ unit = @cadence.unit
201
+ (unit == :day || unit == :month) && !@clauses.allows_month?(start.month)
460
202
  end
461
- end
462
-
463
- def self.prev_day_repeat(interval, days, times, tz, anchor, now)
464
- now_local = tz.utc_to_local(now.utc)
465
- d = now_local.to_date
466
- anchor_date = anchor ? Date.parse(anchor) : EPOCH_DATE
467
-
468
- 1000.times do
469
- if interval > 1
470
- offset = EvalHelpers.days_between(anchor_date, d)
471
- mod = offset % interval
472
- mod += interval if mod.negative?
473
- unless mod.zero?
474
- d -= mod
475
- next
476
- end
477
- end
478
203
 
479
- if EvalHelpers.matches_day_filter(d, days)
480
- candidate = EvalHelpers.latest_past_at_times(d, times, tz, now)
481
- return candidate if candidate
204
+ def candidates_in_period(start)
205
+ case @expr
206
+ when DayRepeat
207
+ Calendar.matches_day_filter?(start, @expr.days) ? [Candidate.on(start)] : []
208
+ when IntervalRepeat
209
+ Calendar.matches_day_filter?(start, @expr.day_filter) ? [Candidate.on(start)] : []
210
+ when WeekRepeat
211
+ @expr.days.map { |day| start + (Weekday.number(day) - 1) }.uniq.sort.map { |date| Candidate.on(date) }
212
+ when MonthRepeat
213
+ Calendar.month_target_dates(start.year, start.month, @expr.target).map { |date| Candidate.new(date, start.month) }
214
+ when YearRepeat
215
+ date = Calendar.year_target_date(start.year, @expr.target)
216
+ date ? [Candidate.on(date)] : []
217
+ when SingleDateExpr
218
+ date = @expr.date.is_a?(IsoDate) ? start : Calendar.date(start.year, MonthName.number(@expr.date.month), @expr.date.day)
219
+ date ? [Candidate.on(date)] : []
482
220
  end
483
-
484
- d -= (interval > 1) ? interval : 1
485
221
  end
486
222
 
487
- nil
488
- end
489
-
490
- def self.prev_interval_repeat(interval, unit, from_time, to_time, day_filter, tz, now)
491
- now_local = tz.utc_to_local(now.utc)
492
- d = now_local.to_date
493
- step_minutes = (unit == IntervalUnit::MIN) ? interval : interval * 60
494
- from_minutes = (from_time.hour * 60) + from_time.minute
495
- to_minutes = (to_time.hour * 60) + to_time.minute
496
-
497
- 1000.times do
498
- if day_filter && !EvalHelpers.matches_day_filter(d, day_filter)
499
- d -= 1
500
- next
501
- end
502
-
503
- # Build list of times in window
504
- window_times = []
505
- m = from_minutes
506
- while m <= to_minutes
507
- window_times << m
508
- m += step_minutes
509
- end
510
-
511
- # Search backwards through window
512
- window_times.reverse_each do |slot|
513
- h = slot / 60
514
- min = slot % 60
515
- candidate = EvalHelpers.at_time_on_date(d, TimeOfDay.new(h, min), tz)
516
- return candidate if candidate && candidate < now
517
- end
518
-
519
- d -= 1
223
+ # An occurrence lands from its scheduled date to shift dates after it, on a first pass,
224
+ # and first passes keep wall-clock order.
225
+ def could_beat?(date, landing, direction, shift)
226
+ direction.forward? ? date <= landing : Calendar.days_between(date, landing) <= shift
520
227
  end
521
228
 
522
- nil
523
- end
524
-
525
- def self.prev_week_repeat(interval, days, times, tz, anchor, now)
526
- anchor_date = anchor ? Date.parse(anchor) : EPOCH_MONDAY
527
- now_local = tz.utc_to_local(now.utc)
528
- d = now_local.to_date
529
-
530
- # Sort target DOWs in descending order for backwards search
531
- sorted_days = days.sort_by { |wd| -Weekday.number(wd) }
532
-
533
- dow_offset = d.cwday - 1
534
- current_monday = d - dow_offset
535
-
536
- anchor_dow_offset = anchor_date.cwday - 1
537
- anchor_monday = anchor_date - anchor_dow_offset
538
-
539
- 54.times do
540
- weeks = EvalHelpers.weeks_between(anchor_monday, current_monday)
541
- return nil if weeks.negative?
542
-
543
- if (weeks % interval).zero?
544
- sorted_days.each do |wd|
545
- day_offset = Weekday.number(wd) - 1
546
- target_date = current_monday + day_offset
547
- candidate = EvalHelpers.latest_past_at_times(target_date, times, tz, now)
548
- return candidate if candidate
549
- end
229
+ def behind?(date, now_date, direction, shift)
230
+ if direction.forward?
231
+ Calendar.days_between(date, now_date) > shift
232
+ else
233
+ Calendar.days_between(now_date, date) > MAX_OVERLAP_DAYS
550
234
  end
551
-
552
- remainder = weeks % interval
553
- skip_weeks = remainder.zero? ? interval : remainder
554
- current_monday -= skip_weeks * 7
555
235
  end
556
236
 
557
- nil
558
- end
237
+ def nearest_on_date(date, now, direction)
238
+ case @times
239
+ when DailyTimes::Fixed
240
+ # Every time is compared: one shifted out of a gap can land after a later wall time.
241
+ nearest = landing = nil
242
+ @times.times.each do |time|
243
+ instant, lands_on = WallClock.fixed_time_on(date, time, @zone)
244
+ next if !direction.precedes?(now, instant) || (nearest && !direction.precedes?(instant, nearest))
559
245
 
560
- def self.prev_month_repeat(interval, target, times, tz, anchor, now)
561
- now_local = tz.utc_to_local(now.utc)
562
- year = now_local.year
563
- month = now_local.month
564
-
565
- anchor_date = anchor ? Date.parse(anchor) : EPOCH_DATE
566
- max_iter = (interval > 1) ? 24 * interval : 24
567
-
568
- max_iter.times do
569
- if interval > 1
570
- cur = Date.new(year, month, 1)
571
- month_offset = EvalHelpers.months_between_ym(anchor_date, cur)
572
- if month_offset.negative? || (month_offset % interval) != 0
573
- month -= 1
574
- if month < 1
575
- month = 12
576
- year -= 1
577
- end
578
- next
246
+ nearest = instant
247
+ landing = lands_on
579
248
  end
580
- end
581
-
582
- date_candidates = []
583
-
584
- case target
585
- when DaysTarget
586
- expanded = Hron.expand_month_target(target)
587
- last = EvalHelpers.last_day_of_month(year, month)
588
- expanded.sort.reverse_each do |day_num|
589
- next unless day_num <= last.day
590
-
591
- begin
592
- date_candidates << Date.new(year, month, day_num)
593
- rescue ArgumentError
594
- # Invalid date
595
- end
596
- end
597
- when LastDayTarget
598
- date_candidates << EvalHelpers.last_day_of_month(year, month)
599
- when LastWeekdayTarget
600
- date_candidates << EvalHelpers.last_weekday_of_month(year, month)
601
- when NearestWeekdayTarget
602
- nw = EvalHelpers.nearest_weekday(year, month, target.day, target.direction)
603
- date_candidates << nw if nw
604
- when OrdinalWeekdayTarget
605
- ordinal_date = if target.ordinal == OrdinalPosition::LAST
606
- EvalHelpers.last_weekday_in_month(year, month, target.weekday)
249
+ Occurrence.new(nearest, landing) if nearest
250
+ when DailyTimes::Slots
251
+ instant = nearest_slot(date, now, direction)
252
+ # A slot outside a gap is the first pass of its wall time, so it lands on its date.
253
+ Occurrence.new(instant, date) if instant
254
+ end
255
+ end
256
+
257
+ # Slot keys never decrease in wall-clock order, so one binary search finds where the
258
+ # slots past now begin.
259
+ def nearest_slot(date, now, direction)
260
+ minutes = @times.minutes
261
+ forward = direction.forward?
262
+ low = 0
263
+ high = minutes.size
264
+ below = above = nil
265
+ while low < high
266
+ middle = (low + high) / 2
267
+ slot = WallClock.slot_on(date, minutes[middle], @zone)
268
+ if forward ? slot.key > now : slot.key >= now
269
+ high = middle
270
+ above = slot
607
271
  else
608
- EvalHelpers.nth_weekday_of_month(year, month, target.weekday, OrdinalPosition.to_n(target.ordinal))
272
+ low = middle + 1
273
+ below = slot
609
274
  end
610
- date_candidates << ordinal_date if ordinal_date
611
275
  end
276
+ index, slot = forward ? [low, above] : [low - 1, below]
277
+ while index >= 0 && index < minutes.size
278
+ slot ||= WallClock.slot_on(date, minutes[index], @zone)
279
+ return slot.instant if slot.instant
612
280
 
613
- # Sort in descending order for backwards search
614
- date_candidates.sort! { |a, b| b <=> a }
615
-
616
- date_candidates.each do |dc|
617
- candidate = EvalHelpers.latest_past_at_times(dc, times, tz, now)
618
- return candidate if candidate
619
- end
620
-
621
- month -= 1
622
- if month < 1
623
- month = 12
624
- year -= 1
625
- end
626
- end
627
-
628
- nil
629
- end
630
-
631
- def self.prev_single_date(date_spec, times, tz, now)
632
- case date_spec
633
- when IsoDate
634
- d = Date.parse(date_spec.date)
635
- EvalHelpers.latest_past_at_times(d, times, tz, now)
636
- when NamedDate
637
- now_local = tz.utc_to_local(now.utc)
638
- start_year = now_local.year
639
- 8.times do |y|
640
- year = start_year - y
641
- begin
642
- d = Date.new(year, MonthName.number(date_spec.month), date_spec.day)
643
- candidate = EvalHelpers.latest_past_at_times(d, times, tz, now)
644
- return candidate if candidate
645
- rescue ArgumentError
646
- # Invalid date
647
- end
281
+ slot = nil
282
+ index += direction.sign
648
283
  end
649
284
  nil
650
285
  end
651
286
  end
652
287
 
653
- def self.prev_year_repeat(interval, target, times, tz, anchor, now)
654
- now_local = tz.utc_to_local(now.utc)
655
- start_year = now_local.year
656
- anchor_year = anchor ? Date.parse(anchor).year : EPOCH_DATE.year
657
-
658
- max_iter = (interval > 1) ? 8 * interval : 8
288
+ Occurrence = Data.define(:instant, :landing)
659
289
 
660
- max_iter.times do |y|
661
- year = start_year - y
662
-
663
- if interval > 1
664
- year_offset = year - anchor_year
665
- next if year_offset.negative? || (year_offset % interval) != 0
666
- end
667
-
668
- target_date = compute_year_target_date(target, year)
669
- next unless target_date
670
-
671
- candidate = EvalHelpers.latest_past_at_times(target_date, times, tz, now)
672
- return candidate if candidate
290
+ # target_month differs from date's month only when a directional nearest weekday crosses
291
+ # into the adjacent month. A Struct, as one is made for every date a search walks and a
292
+ # Data costs twice as much to make.
293
+ Candidate = Struct.new(:date, :target_month) do
294
+ def self.on(date)
295
+ new(date, date.month)
673
296
  end
674
-
675
- nil
676
297
  end
677
298
 
678
- # Returns a lazy Enumerator of occurrences starting after `from`.
679
- # The iterator respects the `until` clause if specified.
680
- def self.occurrences(schedule, from)
681
- Enumerator.new do |yielder|
682
- current = from
683
- loop do
684
- nxt = next_from(schedule, current)
685
- break unless nxt
686
-
687
- yielder << nxt
688
- current = nxt + 60 # Add 1 minute
299
+ module DailyTimes
300
+ Fixed = Data.define(:times) do
301
+ def max_shift_days
302
+ MAX_SHIFT_DAYS
689
303
  end
690
- end.lazy
691
- end
692
-
693
- # Returns a lazy Enumerator of occurrences where from < occurrence <= to.
694
- def self.between(schedule, from, to)
695
- Enumerator.new do |yielder|
696
- occurrences(schedule, from).each do |dt|
697
- break if dt > to
304
+ end
698
305
 
699
- yielder << dt
306
+ # Each slot is skipped in a gap, so it lands on its own date.
307
+ Slots = Data.define(:minutes) do
308
+ def max_shift_days
309
+ 0
700
310
  end
701
- end.lazy
702
- end
703
-
704
- def self.matches(schedule, dt)
705
- tz = TzResolver.resolve(schedule.timezone)
706
- # Convert to local time in the target timezone
707
- dt_local = tz.utc_to_local(dt.utc)
708
- d = dt_local.to_date
709
-
710
- return false if !schedule.during.empty? && !EvalHelpers.matches_during(d, schedule.during)
711
- return false if EvalHelpers.is_excepted(d, schedule.except)
712
-
713
- if schedule.until
714
- until_date = EvalHelpers.resolve_until(schedule.until, dt)
715
- return false if d > until_date
716
311
  end
717
312
 
718
- matches_expr(schedule.expr, schedule.anchor, d, dt_local, tz)
719
- end
313
+ def self.of(expr)
314
+ return Fixed.new(expr.times) unless expr.is_a?(IntervalRepeat)
720
315
 
721
- def self.next_expr(expr, tz, anchor, now, during = [])
722
- case expr
723
- when DayRepeat
724
- next_day_repeat(expr.interval, expr.days, expr.times, tz, anchor, now)
725
- when IntervalRepeat
726
- next_interval_repeat(expr.interval, expr.unit, expr.from_time, expr.to_time, expr.day_filter, tz, now)
727
- when WeekRepeat
728
- next_week_repeat(expr.interval, expr.days, expr.times, tz, anchor, now)
729
- when MonthRepeat
730
- next_month_repeat(expr.interval, expr.target, expr.times, tz, anchor, now, during)
731
- when SingleDateExpr
732
- next_single_date(expr.date, expr.times, tz, now)
733
- when YearRepeat
734
- next_year_repeat(expr.interval, expr.target, expr.times, tz, anchor, now)
316
+ Slots.new(Evaluator.interval_slots(expr.interval, expr.unit, expr.from_time, expr.to_time))
735
317
  end
736
318
  end
737
319
 
738
- def self.matches_expr(expr, anchor, d, dt, tz)
739
- time_matches = ->(times) { time_matches_with_dst(times, d, dt, tz) }
740
-
741
- case expr
742
- when DayRepeat
743
- return false unless EvalHelpers.matches_day_filter(d, expr.days)
744
- return false unless time_matches.call(expr.times)
745
-
746
- if expr.interval > 1
747
- anchor_date = anchor ? Date.parse(anchor) : EPOCH_DATE
748
- day_offset = EvalHelpers.days_between(anchor_date, d)
749
- return day_offset >= 0 && (day_offset % expr.interval).zero?
750
- end
751
- true
752
-
753
- when IntervalRepeat
754
- return false if expr.day_filter && !EvalHelpers.matches_day_filter(d, expr.day_filter)
320
+ # during applies to a candidate's target month; except, until and starting to its date
321
+ # (spec/README.md, "Nearest weekday and `during`", "The `starting` clause").
322
+ class Clauses
323
+ attr_reader :starting
755
324
 
756
- from_minutes = (expr.from_time.hour * 60) + expr.from_time.minute
757
- to_minutes = (expr.to_time.hour * 60) + expr.to_time.minute
758
- current_minutes = (dt.hour * 60) + dt.min
759
- return false if current_minutes < from_minutes || current_minutes > to_minutes
760
-
761
- diff = current_minutes - from_minutes
762
- step = (expr.unit == IntervalUnit::MIN) ? expr.interval : expr.interval * 60
763
- diff >= 0 && (diff % step).zero?
764
-
765
- when WeekRepeat
766
- dow = d.cwday
767
- return false unless expr.days.any? { |wd| Weekday.number(wd) == dow }
768
- return false unless time_matches.call(expr.times)
769
-
770
- anchor_date = anchor ? Date.parse(anchor) : EPOCH_MONDAY
771
- weeks = EvalHelpers.weeks_between(anchor_date, d)
772
- weeks >= 0 && (weeks % expr.interval).zero?
773
-
774
- when MonthRepeat
775
- return false unless time_matches.call(expr.times)
776
-
777
- if expr.interval > 1
778
- anchor_date = anchor ? Date.parse(anchor) : EPOCH_DATE
779
- month_offset = EvalHelpers.months_between_ym(anchor_date, d)
780
- return false if month_offset.negative? || (month_offset % expr.interval) != 0
781
- end
782
- matches_month_target(expr.target, d)
783
-
784
- when SingleDateExpr
785
- return false unless time_matches.call(expr.times)
786
-
787
- matches_date_spec(expr.date, d)
788
-
789
- when YearRepeat
790
- return false unless time_matches.call(expr.times)
791
-
792
- if expr.interval > 1
793
- anchor_year = anchor ? Date.parse(anchor).year : EPOCH_DATE.year
794
- year_offset = d.year - anchor_year
795
- return false if year_offset.negative? || (year_offset % expr.interval) != 0
796
- end
797
- matches_year_target(expr.target, d)
798
-
799
- else
800
- false
325
+ def initialize(schedule)
326
+ @starting = schedule.starting && Calendar.parse_date(schedule.starting)
327
+ @until = schedule.until && resolve_until(schedule.until, @starting)
328
+ named, iso = schedule.except.partition { |exception| exception.is_a?(NamedException) }
329
+ @except_month_days = named.map { |exception| [MonthName.number(exception.month), exception.day] }
330
+ @except_dates = iso.map { |exception| Calendar.parse_date(exception.date) }
331
+ @during = schedule.during.map { |month| MonthName.number(month) }
801
332
  end
802
- end
803
333
 
804
- def self.time_matches_with_dst(times, d, dt, tz)
805
- times.any? do |tod|
806
- if dt.hour == tod.hour && dt.min == tod.minute
807
- true
808
- else
809
- # DST gap check
810
- resolved = EvalHelpers.at_time_on_date(d, tod, tz)
811
- resolved && resolved.to_i == dt.to_i
812
- end
334
+ def allows?(candidate)
335
+ date = candidate.date
336
+ allows_month?(candidate.target_month) &&
337
+ @except_month_days.none? { |month, day| date.month == month && date.day == day } &&
338
+ !@except_dates.include?(date) &&
339
+ (@until.nil? || date <= @until) &&
340
+ (@starting.nil? || date >= @starting)
813
341
  end
814
- end
815
342
 
816
- def self.matches_month_target(target, d)
817
- case target
818
- when DaysTarget
819
- expanded = Hron.expand_month_target(target)
820
- expanded.include?(d.day)
821
- when LastDayTarget
822
- d == EvalHelpers.last_day_of_month(d.year, d.month)
823
- when LastWeekdayTarget
824
- d == EvalHelpers.last_weekday_of_month(d.year, d.month)
825
- when NearestWeekdayTarget
826
- target_date = EvalHelpers.nearest_weekday(d.year, d.month, target.day, target.direction)
827
- target_date && d == target_date
828
- when OrdinalWeekdayTarget
829
- ordinal_date = if target.ordinal == OrdinalPosition::LAST
830
- EvalHelpers.last_weekday_in_month(d.year, d.month, target.weekday)
831
- else
832
- EvalHelpers.nth_weekday_of_month(d.year, d.month, target.weekday, OrdinalPosition.to_n(target.ordinal))
833
- end
834
- ordinal_date && d == ordinal_date
835
- else
836
- false
343
+ def allows_month?(month)
344
+ @during.empty? || @during.include?(month)
837
345
  end
838
- end
839
346
 
840
- def self.matches_date_spec(date_spec, d)
841
- case date_spec
842
- when IsoDate
843
- d == Date.parse(date_spec.date)
844
- when NamedDate
845
- d.month == MonthName.number(date_spec.month) && d.day == date_spec.day
846
- else
847
- false
347
+ def end_on(date)
348
+ @until = date if @until.nil? || date < @until
848
349
  end
849
- end
850
-
851
- def self.matches_year_target(target, d)
852
- case target
853
- when YearDateTarget
854
- d.month == MonthName.number(target.month) && d.day == target.day
855
- when YearOrdinalWeekdayTarget
856
- return false if d.month != MonthName.number(target.month)
857
350
 
858
- ordinal_date = if target.ordinal == OrdinalPosition::LAST
859
- EvalHelpers.last_weekday_in_month(d.year, d.month, target.weekday)
860
- else
861
- EvalHelpers.nth_weekday_of_month(d.year, d.month, target.weekday,
862
- OrdinalPosition.to_n(target.ordinal))
863
- end
864
- ordinal_date && d == ordinal_date
865
- when YearDayOfMonthTarget
866
- d.month == MonthName.number(target.month) && d.day == target.day
867
- when YearLastWeekdayTarget
868
- return false if d.month != MonthName.number(target.month)
869
-
870
- d == EvalHelpers.last_weekday_of_month(d.year, d.month)
871
- else
872
- false
351
+ # The one-off except date farthest along direction: the calendar repeats only beyond
352
+ # it (spec/README.md, "Search horizon").
353
+ def farthest_except_date(direction)
354
+ direction.forward? ? @except_dates.max : @except_dates.min
873
355
  end
874
- end
875
-
876
- # Per-variant next functions
877
- def self.next_day_repeat(interval, days, times, tz, anchor, now)
878
- now_local = tz.utc_to_local(now.utc)
879
- d = now_local.to_date
880
-
881
- if interval <= 1
882
- if EvalHelpers.matches_day_filter(d, days)
883
- candidate = EvalHelpers.earliest_future_at_times(d, times, tz, now)
884
- return candidate if candidate
885
- end
886
356
 
887
- 8.times do
888
- d += 1
889
- if EvalHelpers.matches_day_filter(d, days)
890
- candidate = EvalHelpers.earliest_future_at_times(d, times, tz, now)
891
- return candidate if candidate
892
- end
357
+ def clamp(date, direction)
358
+ if direction.forward?
359
+ @starting ? [date, @starting].max : date
360
+ else
361
+ @until ? [date, @until].min : date
893
362
  end
894
-
895
- return nil
896
- end
897
-
898
- # Interval > 1
899
- anchor_date = anchor ? Date.parse(anchor) : EPOCH_DATE
900
- offset = EvalHelpers.days_between(anchor_date, d)
901
- remainder = offset % interval
902
- aligned_date = remainder.zero? ? d : d + (interval - remainder)
903
-
904
- 400.times do
905
- candidate = EvalHelpers.earliest_future_at_times(aligned_date, times, tz, now)
906
- return candidate if candidate
907
-
908
- aligned_date += interval
909
363
  end
910
364
 
911
- nil
912
- end
913
-
914
- def self.next_interval_repeat(interval, unit, from_time, to_time, day_filter, tz, now)
915
- step_minutes = (unit == IntervalUnit::MIN) ? interval : interval * 60
916
- from_minutes = (from_time.hour * 60) + from_time.minute
917
- to_minutes = (to_time.hour * 60) + to_time.minute
918
-
919
- # Convert now to local time in the target timezone
920
- now_local = tz.utc_to_local(now.utc)
921
- d = now_local.to_date
922
-
923
- 400.times do
924
- if day_filter && !EvalHelpers.matches_day_filter(d, day_filter)
925
- d += 1
926
- next
927
- end
928
-
929
- same_day = d == now_local.to_date
930
- now_minutes = same_day ? (now_local.hour * 60) + now_local.min : -1
931
-
932
- next_slot = if now_minutes < from_minutes
933
- from_minutes
365
+ def ends_search?(date, direction)
366
+ if direction.forward?
367
+ !@until.nil? && date > @until
934
368
  else
935
- elapsed = now_minutes - from_minutes
936
- from_minutes + (((elapsed / step_minutes) + 1) * step_minutes)
369
+ !@starting.nil? && date < @starting
937
370
  end
371
+ end
938
372
 
939
- # Try each slot within the day's window until we find one that exists
940
- # This handles DST gaps where intermediate times don't exist
941
- while next_slot <= to_minutes
942
- h = next_slot / 60
943
- m = next_slot % 60
944
- candidate = EvalHelpers.at_time_on_date(d, TimeOfDay.new(h, m), tz)
945
- return candidate if candidate && candidate > now
373
+ private
946
374
 
947
- # Slot didn't exist (DST gap) or wasn't in the future, try next slot
948
- next_slot += step_minutes
375
+ # A named until date is the first such date on or after the starting date, which a
376
+ # named until always has (spec/README.md, "Named `until`"). nil when the date never
377
+ # occurs, so nothing bounds the schedule.
378
+ def resolve_until(until_spec, starting)
379
+ case until_spec
380
+ when IsoUntil then Calendar.parse_date(until_spec.date)
381
+ when NamedUntil
382
+ month = MonthName.number(until_spec.month)
383
+ (0..NAMED_UNTIL_MAX_YEARS)
384
+ .filter_map { |k| Calendar.date(starting.year + k, month, until_spec.day) }
385
+ .find { |date| date >= starting }
949
386
  end
950
-
951
- d += 1
952
387
  end
953
-
954
- nil
955
388
  end
956
389
 
957
- def self.next_week_repeat(interval, days, times, tz, anchor, now)
958
- anchor_date = anchor ? Date.parse(anchor) : EPOCH_MONDAY
959
-
960
- now_local = tz.utc_to_local(now.utc)
961
- d = now_local.to_date
962
- sorted_days = days.sort_by { |wd| Weekday.number(wd) }
390
+ class Cadence
391
+ attr_reader :unit
963
392
 
964
- dow_offset = d.cwday - 1
965
- current_monday = d - dow_offset
393
+ # Units in 400 years, after which the proleptic Gregorian calendar repeats.
394
+ PER_400_YEARS = {day: 146_097, week: 20_871, month: 4800, year: 400}.freeze
966
395
 
967
- anchor_dow_offset = anchor_date.cwday - 1
968
- anchor_monday = anchor_date - anchor_dow_offset
969
-
970
- 54.times do
971
- weeks = EvalHelpers.weeks_between(anchor_monday, current_monday)
972
-
973
- if weeks.negative?
974
- # When weeks_since_anchor < 0, anchor_monday is in the future
975
- # Use anchor_monday directly as the first aligned week
976
- current_monday = anchor_monday
977
- next
396
+ def self.of(expr, starting)
397
+ unit, interval, default_anchor = case expr
398
+ in SingleDateExpr[date: IsoDate[date:]] then return new(:day, Calendar.parse_date(date), 1, single: true)
399
+ in SingleDateExpr then [:year, 1, EPOCH_DATE]
400
+ in IntervalRepeat then [:day, 1, EPOCH_DATE]
401
+ in DayRepeat then [:day, expr.interval, EPOCH_DATE]
402
+ in WeekRepeat then [:week, expr.interval, EPOCH_MONDAY]
403
+ in MonthRepeat then [:month, expr.interval, EPOCH_DATE]
404
+ in YearRepeat then [:year, expr.interval, EPOCH_DATE]
978
405
  end
979
-
980
- if (weeks % interval).zero?
981
- sorted_days.each do |wd|
982
- day_offset = Weekday.number(wd) - 1
983
- target_date = current_monday + day_offset
984
- candidate = EvalHelpers.earliest_future_at_times(target_date, times, tz, now)
985
- return candidate if candidate
986
- end
406
+ anchor = starting || default_anchor
407
+ origin = case unit
408
+ when :day then anchor
409
+ when :week then Calendar.monday_of_week(anchor)
410
+ when :month then Calendar.date(anchor.year, anchor.month, 1)
411
+ when :year then Calendar.first_of_year(anchor.year)
987
412
  end
988
-
989
- remainder = weeks % interval
990
- skip_weeks = remainder.zero? ? interval : interval - remainder
991
- current_monday += skip_weeks * 7
413
+ new(unit, origin, interval)
992
414
  end
993
415
 
994
- nil
995
- end
996
-
997
- def self.next_month_repeat(interval, target, times, tz, anchor, now, during = [])
998
- now_local = tz.utc_to_local(now.utc)
999
- year = now_local.year
1000
- month = now_local.month
1001
-
1002
- anchor_date = anchor ? Date.parse(anchor) : EPOCH_DATE
1003
- max_iter = (interval > 1) ? 24 * interval : 24
1004
-
1005
- # For NearestWeekday with direction, we need to apply the during filter here
1006
- # because the result can cross month boundaries
1007
- apply_during_filter = !during.empty? &&
1008
- target.is_a?(NearestWeekdayTarget) &&
1009
- !target.direction.nil?
1010
-
1011
- max_iter.times do
1012
- # Check during filter for NearestWeekday with direction
1013
- if apply_during_filter && !during.any? { |mn| MonthName.number(mn) == month }
1014
- month += 1
1015
- if month > 12
1016
- month = 1
1017
- year += 1
1018
- end
1019
- next
1020
- end
1021
-
1022
- if interval > 1
1023
- cur = Date.new(year, month, 1)
1024
- month_offset = EvalHelpers.months_between_ym(anchor_date, cur)
1025
- if month_offset.negative? || (month_offset % interval) != 0
1026
- month += 1
1027
- if month > 12
1028
- month = 1
1029
- year += 1
1030
- end
1031
- next
1032
- end
1033
- end
1034
-
1035
- date_candidates = []
1036
-
1037
- case target
1038
- when DaysTarget
1039
- expanded = Hron.expand_month_target(target)
1040
- last = EvalHelpers.last_day_of_month(year, month)
1041
- expanded.each do |day_num|
1042
- next unless day_num <= last.day
1043
-
1044
- begin
1045
- date_candidates << Date.new(year, month, day_num)
1046
- rescue ArgumentError
1047
- # Invalid date
1048
- end
1049
- end
1050
- when LastDayTarget
1051
- date_candidates << EvalHelpers.last_day_of_month(year, month)
1052
- when LastWeekdayTarget
1053
- date_candidates << EvalHelpers.last_weekday_of_month(year, month)
1054
- when NearestWeekdayTarget
1055
- nw = EvalHelpers.nearest_weekday(year, month, target.day, target.direction)
1056
- date_candidates << nw if nw
1057
- when OrdinalWeekdayTarget
1058
- ordinal_date = if target.ordinal == OrdinalPosition::LAST
1059
- EvalHelpers.last_weekday_in_month(year, month, target.weekday)
1060
- else
1061
- EvalHelpers.nth_weekday_of_month(year, month, target.weekday, OrdinalPosition.to_n(target.ordinal))
1062
- end
1063
- date_candidates << ordinal_date if ordinal_date
1064
- end
1065
-
1066
- best = nil
1067
- date_candidates.each do |dc|
1068
- candidate = EvalHelpers.earliest_future_at_times(dc, times, tz, now)
1069
- best = candidate if candidate && (best.nil? || candidate < best)
1070
- end
1071
- return best if best
416
+ def initialize(unit, origin, interval, single: false)
417
+ @unit = unit
418
+ @origin = origin
419
+ @interval = interval
420
+ @single = single
421
+ @origin_month = Calendar.month_index(origin)
422
+ # A nearest weekday can move a candidate into the calendar from the period on
423
+ # either side of it.
424
+ @earliest = period_of(Calendar::FIRST_DATE) - 1
425
+ @latest = period_of(Calendar::LAST_DATE) + 1
426
+ end
1072
427
 
1073
- month += 1
1074
- if month > 12
1075
- month = 1
1076
- year += 1
428
+ def period_of(date)
429
+ case @unit
430
+ when :day then Calendar.days_between(@origin, date)
431
+ when :week then Calendar.days_between(@origin, date).div(7)
432
+ when :month then Calendar.month_index(date) - @origin_month
433
+ when :year then date.year - @origin.year
1077
434
  end
1078
435
  end
1079
436
 
1080
- nil
1081
- end
1082
-
1083
- def self.next_single_date(date_spec, times, tz, now)
1084
- case date_spec
1085
- when IsoDate
1086
- d = Date.parse(date_spec.date)
1087
- EvalHelpers.earliest_future_at_times(d, times, tz, now)
1088
- when NamedDate
1089
- now_local = tz.utc_to_local(now.utc)
1090
- start_year = now_local.year
1091
- 8.times do |y|
1092
- year = start_year + y
1093
- begin
1094
- d = Date.new(year, MonthName.number(date_spec.month), date_spec.day)
1095
- candidate = EvalHelpers.earliest_future_at_times(d, times, tz, now)
1096
- return candidate if candidate
1097
- rescue ArgumentError
1098
- # Invalid date
1099
- end
437
+ def start_of(k)
438
+ case @unit
439
+ when :day then @origin + k
440
+ when :week then @origin + (k * 7)
441
+ when :month then Calendar.first_of_month(@origin_month + k)
442
+ when :year then Calendar.first_of_year(@origin.year + k)
1100
443
  end
1101
- nil
1102
444
  end
1103
- end
1104
445
 
1105
- def self.next_year_repeat(interval, target, times, tz, anchor, now)
1106
- now_local = tz.utc_to_local(now.utc)
1107
- start_year = now_local.year
1108
- anchor_year = anchor ? Date.parse(anchor).year : EPOCH_DATE.year
446
+ # Walks one search horizon beyond whichever of first_period and reach is farther along
447
+ # direction (spec/README.md, "Search horizon").
448
+ def period_starts(first_period, reach, direction)
449
+ return yield @origin if @single
1109
450
 
1110
- max_iter = (interval > 1) ? 8 * interval : 8
1111
-
1112
- max_iter.times do |y|
1113
- year = start_year + y
1114
-
1115
- if interval > 1
1116
- year_offset = year - anchor_year
1117
- next if year_offset.negative? || (year_offset % interval) != 0
1118
- end
451
+ first = align(first_period, direction)
452
+ beyond = direction.sign * (align(reach, direction) - first)
453
+ count = horizon_periods + HORIZON_MARGIN_PERIODS + ([beyond, 0].max / @interval)
454
+ # A walk starts inside the calendar, at now or a starting date, and ends at its edge.
455
+ far = direction.sign * ((direction.forward? ? @latest : @earliest) - first)
456
+ inside = 0...[(far / @interval) + 1, count].min
457
+ step = direction.sign * @interval
458
+ inside.each { |i| yield start_of(first + (i * step)) }
459
+ end
1119
460
 
1120
- target_date = compute_year_target_date(target, year)
1121
- next unless target_date
461
+ private
1122
462
 
1123
- candidate = EvalHelpers.earliest_future_at_times(target_date, times, tz, now)
1124
- return candidate if candidate
463
+ def align(k, direction)
464
+ direction.forward? ? k + (-k % @interval) : k - (k % @interval)
1125
465
  end
1126
466
 
1127
- nil
1128
- end
1129
-
1130
- def self.compute_year_target_date(target, year)
1131
- case target
1132
- when YearDateTarget
1133
- Date.new(year, MonthName.number(target.month), target.day)
1134
- when YearOrdinalWeekdayTarget
1135
- if target.ordinal == OrdinalPosition::LAST
1136
- EvalHelpers.last_weekday_in_month(year, MonthName.number(target.month), target.weekday)
1137
- else
1138
- EvalHelpers.nth_weekday_of_month(year, MonthName.number(target.month), target.weekday,
1139
- OrdinalPosition.to_n(target.ordinal))
1140
- end
1141
- when YearDayOfMonthTarget
1142
- Date.new(year, MonthName.number(target.month), target.day)
1143
- when YearLastWeekdayTarget
1144
- EvalHelpers.last_weekday_of_month(year, MonthName.number(target.month))
467
+ # Aligned periods in lcm(400 years, interval units), after which both the calendar and
468
+ # the alignment repeat.
469
+ def horizon_periods
470
+ cycle = PER_400_YEARS.fetch(@unit)
471
+ cycle / cycle.gcd(@interval)
1145
472
  end
1146
- rescue ArgumentError
1147
- nil
1148
473
  end
474
+
475
+ private_constant :EPOCH_MONDAY, :EPOCH_DATE, :SUPPORTED_RANGE, :HORIZON_MARGIN_PERIODS, :MAX_SHIFT_DAYS, :NAMED_UNTIL_MAX_YEARS,
476
+ :Direction, :Search, :Occurrence, :Candidate, :DailyTimes, :Clauses, :Cadence,
477
+ :Calendar, :WallClock
1149
478
  end
1150
479
  end