hron 0.5.1

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.
@@ -0,0 +1,725 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "time"
5
+ require "tzinfo"
6
+ require_relative "ast"
7
+ require_relative "error"
8
+
9
+ module Hron
10
+ EPOCH_DATE = Date.new(1970, 1, 1)
11
+ EPOCH_MONDAY = Date.new(1970, 1, 5)
12
+
13
+ # Timezone resolution
14
+ module TzResolver
15
+ def self.resolve(tz_name)
16
+ if tz_name && !tz_name.empty?
17
+ TZInfo::Timezone.get(tz_name)
18
+ else
19
+ # Default to UTC for deterministic, portable behavior
20
+ TZInfo::Timezone.get("UTC")
21
+ end
22
+ end
23
+ end
24
+
25
+ # Evaluator helpers
26
+ module EvalHelpers
27
+ def self.at_time_on_date(d, tod, tz)
28
+ # Create local time representation (using UTC to avoid system TZ interference)
29
+ local_time = Time.utc(d.year, d.month, d.day, tod.hour, tod.minute, 0)
30
+
31
+ # Get periods for this local time
32
+ periods = tz.periods_for_local(local_time)
33
+
34
+ case periods.length
35
+ when 0
36
+ # Time doesn't exist (spring forward gap)
37
+ # Push the time forward past the gap (like "compatible" disambiguation)
38
+ # For example: 02:30 during DST spring forward -> 03:30
39
+
40
+ # Find the transition on this date
41
+ day_start = Time.utc(d.year, d.month, d.day, 0, 0, 0)
42
+ day_end = Time.utc(d.year, d.month, d.day, 23, 59, 59)
43
+ transitions = tz.transitions_up_to(day_end, day_start)
44
+
45
+ if transitions.any?
46
+ # Find the spring-forward transition (where offset increases / DST starts)
47
+ transition = transitions.find { |t| t.offset.dst? }
48
+ if transition
49
+ # The transition time is when the gap starts
50
+ # We need to push the requested time forward by the gap size
51
+ prev_offset = transition.previous_offset.utc_total_offset
52
+ new_offset = transition.offset.utc_total_offset
53
+ gap_seconds = new_offset - prev_offset # Positive for spring forward
54
+
55
+ # Push the local time forward by the gap amount
56
+ # E.g., 02:30 + 1 hour = 03:30 local
57
+ pushed_local = local_time + gap_seconds
58
+
59
+ # Convert the pushed local time to UTC using the new offset
60
+ # E.g., 03:30 local EDT (-4h) -> 07:30 UTC
61
+ return pushed_local - new_offset
62
+ end
63
+ end
64
+
65
+ # Fallback: try to construct the time and let TZInfo handle it
66
+ # This shouldn't normally be reached
67
+ begin
68
+ tz.local_to_utc(local_time)
69
+ rescue TZInfo::AmbiguousTime, TZInfo::PeriodNotFound
70
+ nil
71
+ end
72
+ when 1
73
+ # Unambiguous time - straightforward conversion
74
+ period = periods[0]
75
+ utc_offset = period.offset.utc_total_offset
76
+ local_time - utc_offset
77
+ when 2
78
+ # Ambiguous time (fall back) - use first occurrence (pre-transition)
79
+ # Period 0 is the earlier offset (e.g., EDT -04:00 before fall back)
80
+ period = periods[0]
81
+ utc_offset = period.offset.utc_total_offset
82
+ local_time - utc_offset
83
+ end
84
+ end
85
+
86
+ def self.matches_day_filter(d, filter)
87
+ dow = d.cwday # Monday=1 ... Sunday=7
88
+ case filter
89
+ when DayFilterEvery
90
+ true
91
+ when DayFilterWeekday
92
+ dow.between?(1, 5)
93
+ when DayFilterWeekend
94
+ [6, 7].include?(dow)
95
+ when DayFilterDays
96
+ filter.days.any? { |wd| Weekday.number(wd) == dow }
97
+ else
98
+ false
99
+ end
100
+ end
101
+
102
+ def self.last_day_of_month(year, month)
103
+ Date.new(year, month, -1)
104
+ end
105
+
106
+ def self.last_weekday_of_month(year, month)
107
+ d = last_day_of_month(year, month)
108
+ d -= 1 while d.cwday >= 6
109
+ d
110
+ end
111
+
112
+ def self.nth_weekday_of_month(year, month, weekday, n)
113
+ target_dow = Weekday.number(weekday)
114
+ d = Date.new(year, month, 1)
115
+ d += 1 while d.cwday != target_dow
116
+ (n - 1).times { d += 7 }
117
+ return nil if d.month != month
118
+
119
+ d
120
+ end
121
+
122
+ def self.last_weekday_in_month(year, month, weekday)
123
+ target_dow = Weekday.number(weekday)
124
+ d = last_day_of_month(year, month)
125
+ d -= 1 while d.cwday != target_dow
126
+ d
127
+ end
128
+
129
+ def self.weeks_between(a, b)
130
+ (b - a).to_i / 7
131
+ end
132
+
133
+ def self.days_between(a, b)
134
+ (b - a).to_i
135
+ end
136
+
137
+ def self.months_between_ym(a, b)
138
+ (b.year * 12) + b.month - ((a.year * 12) + a.month)
139
+ end
140
+
141
+ def self.is_excepted(d, exceptions)
142
+ exceptions.any? do |exc|
143
+ case exc
144
+ when NamedException
145
+ d.month == MonthName.number(exc.month) && d.day == exc.day
146
+ when IsoException
147
+ d == Date.parse(exc.date)
148
+ else
149
+ false
150
+ end
151
+ end
152
+ end
153
+
154
+ def self.matches_during(d, during)
155
+ return true if during.empty?
156
+
157
+ during.any? { |mn| MonthName.number(mn) == d.month }
158
+ end
159
+
160
+ def self.next_during_month(d, during)
161
+ months = during.map { |mn| MonthName.number(mn) }.sort
162
+
163
+ months.each do |m|
164
+ return Date.new(d.year, m, 1) if m > d.month
165
+ end
166
+
167
+ # Wrap to first month of next year
168
+ Date.new(d.year + 1, months[0], 1)
169
+ end
170
+
171
+ def self.resolve_until(until_spec, now)
172
+ case until_spec
173
+ when IsoUntil
174
+ Date.parse(until_spec.date)
175
+ when NamedUntil
176
+ year = now.year
177
+ [year, year + 1].each do |y|
178
+ d = Date.new(y, MonthName.number(until_spec.month), until_spec.day)
179
+ return d if d >= now.to_date
180
+ rescue ArgumentError
181
+ next
182
+ end
183
+ Date.new(year + 1, MonthName.number(until_spec.month), until_spec.day)
184
+ end
185
+ end
186
+
187
+ def self.earliest_future_at_times(d, times, tz, now)
188
+ best = nil
189
+ times.each do |tod|
190
+ candidate = at_time_on_date(d, tod, tz)
191
+ next unless candidate
192
+
193
+ best = candidate if candidate > now && (best.nil? || candidate < best)
194
+ end
195
+ best
196
+ end
197
+ end
198
+
199
+ # Main evaluator class
200
+ class Evaluator
201
+ def self.next_from(schedule, now)
202
+ tz = TzResolver.resolve(schedule.timezone)
203
+ until_date = schedule.until ? EvalHelpers.resolve_until(schedule.until, now) : nil
204
+ has_exceptions = !schedule.except.empty?
205
+ has_during = !schedule.during.empty?
206
+
207
+ current = now
208
+ 1000.times do
209
+ candidate = next_expr(schedule.expr, tz, schedule.anchor, current)
210
+ return nil unless candidate
211
+
212
+ c_date = candidate.to_date
213
+
214
+ # Apply until filter
215
+ return nil if until_date && c_date > until_date
216
+
217
+ # Apply during filter
218
+ if has_during && !EvalHelpers.matches_during(c_date, schedule.during)
219
+ skip_to = EvalHelpers.next_during_month(c_date, schedule.during)
220
+ midnight = EvalHelpers.at_time_on_date(skip_to, TimeOfDay.new(0, 0), tz)
221
+ current = midnight - 1
222
+ next
223
+ end
224
+
225
+ # Apply except filter
226
+ if has_exceptions && EvalHelpers.is_excepted(c_date, schedule.except)
227
+ next_day = c_date + 1
228
+ midnight = EvalHelpers.at_time_on_date(next_day, TimeOfDay.new(0, 0), tz)
229
+ current = midnight - 1
230
+ next
231
+ end
232
+
233
+ return candidate
234
+ end
235
+
236
+ nil
237
+ end
238
+
239
+ def self.next_n_from(schedule, now, n)
240
+ results = []
241
+ current = now
242
+ n.times do
243
+ nxt = next_from(schedule, current)
244
+ break unless nxt
245
+
246
+ results << nxt
247
+ current = nxt + 60 # Add 1 minute
248
+ end
249
+ results
250
+ end
251
+
252
+ def self.matches(schedule, dt)
253
+ tz = TzResolver.resolve(schedule.timezone)
254
+ # Convert to local time in the target timezone
255
+ dt_local = tz.utc_to_local(dt.utc)
256
+ d = dt_local.to_date
257
+
258
+ return false if !schedule.during.empty? && !EvalHelpers.matches_during(d, schedule.during)
259
+ return false if EvalHelpers.is_excepted(d, schedule.except)
260
+
261
+ if schedule.until
262
+ until_date = EvalHelpers.resolve_until(schedule.until, dt)
263
+ return false if d > until_date
264
+ end
265
+
266
+ matches_expr(schedule.expr, schedule.anchor, d, dt_local, tz)
267
+ end
268
+
269
+ def self.next_expr(expr, tz, anchor, now)
270
+ case expr
271
+ when DayRepeat
272
+ next_day_repeat(expr.interval, expr.days, expr.times, tz, anchor, now)
273
+ when IntervalRepeat
274
+ next_interval_repeat(expr.interval, expr.unit, expr.from_time, expr.to_time, expr.day_filter, tz, now)
275
+ when WeekRepeat
276
+ next_week_repeat(expr.interval, expr.days, expr.times, tz, anchor, now)
277
+ when MonthRepeat
278
+ next_month_repeat(expr.interval, expr.target, expr.times, tz, anchor, now)
279
+ when OrdinalRepeat
280
+ next_ordinal_repeat(expr.interval, expr.ordinal, expr.day, expr.times, tz, anchor, now)
281
+ when SingleDateExpr
282
+ next_single_date(expr.date, expr.times, tz, now)
283
+ when YearRepeat
284
+ next_year_repeat(expr.interval, expr.target, expr.times, tz, anchor, now)
285
+ end
286
+ end
287
+
288
+ def self.matches_expr(expr, anchor, d, dt, tz)
289
+ time_matches = ->(times) { time_matches_with_dst(times, d, dt, tz) }
290
+
291
+ case expr
292
+ when DayRepeat
293
+ return false unless EvalHelpers.matches_day_filter(d, expr.days)
294
+ return false unless time_matches.call(expr.times)
295
+
296
+ if expr.interval > 1
297
+ anchor_date = anchor ? Date.parse(anchor) : EPOCH_DATE
298
+ day_offset = EvalHelpers.days_between(anchor_date, d)
299
+ return day_offset >= 0 && (day_offset % expr.interval).zero?
300
+ end
301
+ true
302
+
303
+ when IntervalRepeat
304
+ return false if expr.day_filter && !EvalHelpers.matches_day_filter(d, expr.day_filter)
305
+
306
+ from_minutes = (expr.from_time.hour * 60) + expr.from_time.minute
307
+ to_minutes = (expr.to_time.hour * 60) + expr.to_time.minute
308
+ current_minutes = (dt.hour * 60) + dt.min
309
+ return false if current_minutes < from_minutes || current_minutes > to_minutes
310
+
311
+ diff = current_minutes - from_minutes
312
+ step = (expr.unit == IntervalUnit::MIN) ? expr.interval : expr.interval * 60
313
+ diff >= 0 && (diff % step).zero?
314
+
315
+ when WeekRepeat
316
+ dow = d.cwday
317
+ return false unless expr.days.any? { |wd| Weekday.number(wd) == dow }
318
+ return false unless time_matches.call(expr.times)
319
+
320
+ anchor_date = anchor ? Date.parse(anchor) : EPOCH_MONDAY
321
+ weeks = EvalHelpers.weeks_between(anchor_date, d)
322
+ weeks >= 0 && (weeks % expr.interval).zero?
323
+
324
+ when MonthRepeat
325
+ return false unless time_matches.call(expr.times)
326
+
327
+ if expr.interval > 1
328
+ anchor_date = anchor ? Date.parse(anchor) : EPOCH_DATE
329
+ month_offset = EvalHelpers.months_between_ym(anchor_date, d)
330
+ return false if month_offset.negative? || (month_offset % expr.interval) != 0
331
+ end
332
+ matches_month_target(expr.target, d)
333
+
334
+ when OrdinalRepeat
335
+ return false unless time_matches.call(expr.times)
336
+
337
+ if expr.interval > 1
338
+ anchor_date = anchor ? Date.parse(anchor) : EPOCH_DATE
339
+ month_offset = EvalHelpers.months_between_ym(anchor_date, d)
340
+ return false if month_offset.negative? || (month_offset % expr.interval) != 0
341
+ end
342
+
343
+ ordinal_target = if expr.ordinal == OrdinalPosition::LAST
344
+ EvalHelpers.last_weekday_in_month(d.year, d.month, expr.day)
345
+ else
346
+ EvalHelpers.nth_weekday_of_month(d.year, d.month, expr.day,
347
+ OrdinalPosition.to_n(expr.ordinal))
348
+ end
349
+ return false unless ordinal_target
350
+
351
+ d == ordinal_target
352
+
353
+ when SingleDateExpr
354
+ return false unless time_matches.call(expr.times)
355
+
356
+ matches_date_spec(expr.date, d)
357
+
358
+ when YearRepeat
359
+ return false unless time_matches.call(expr.times)
360
+
361
+ if expr.interval > 1
362
+ anchor_year = anchor ? Date.parse(anchor).year : EPOCH_DATE.year
363
+ year_offset = d.year - anchor_year
364
+ return false if year_offset.negative? || (year_offset % expr.interval) != 0
365
+ end
366
+ matches_year_target(expr.target, d)
367
+
368
+ else
369
+ false
370
+ end
371
+ end
372
+
373
+ def self.time_matches_with_dst(times, d, dt, tz)
374
+ times.any? do |tod|
375
+ if dt.hour == tod.hour && dt.min == tod.minute
376
+ true
377
+ else
378
+ # DST gap check
379
+ resolved = EvalHelpers.at_time_on_date(d, tod, tz)
380
+ resolved && resolved.to_i == dt.to_i
381
+ end
382
+ end
383
+ end
384
+
385
+ def self.matches_month_target(target, d)
386
+ case target
387
+ when DaysTarget
388
+ expanded = Hron.expand_month_target(target)
389
+ expanded.include?(d.day)
390
+ when LastDayTarget
391
+ d == EvalHelpers.last_day_of_month(d.year, d.month)
392
+ when LastWeekdayTarget
393
+ d == EvalHelpers.last_weekday_of_month(d.year, d.month)
394
+ else
395
+ false
396
+ end
397
+ end
398
+
399
+ def self.matches_date_spec(date_spec, d)
400
+ case date_spec
401
+ when IsoDate
402
+ d == Date.parse(date_spec.date)
403
+ when NamedDate
404
+ d.month == MonthName.number(date_spec.month) && d.day == date_spec.day
405
+ else
406
+ false
407
+ end
408
+ end
409
+
410
+ def self.matches_year_target(target, d)
411
+ case target
412
+ when YearDateTarget
413
+ d.month == MonthName.number(target.month) && d.day == target.day
414
+ when YearOrdinalWeekdayTarget
415
+ return false if d.month != MonthName.number(target.month)
416
+
417
+ ordinal_date = if target.ordinal == OrdinalPosition::LAST
418
+ EvalHelpers.last_weekday_in_month(d.year, d.month, target.weekday)
419
+ else
420
+ EvalHelpers.nth_weekday_of_month(d.year, d.month, target.weekday,
421
+ OrdinalPosition.to_n(target.ordinal))
422
+ end
423
+ ordinal_date && d == ordinal_date
424
+ when YearDayOfMonthTarget
425
+ d.month == MonthName.number(target.month) && d.day == target.day
426
+ when YearLastWeekdayTarget
427
+ return false if d.month != MonthName.number(target.month)
428
+
429
+ d == EvalHelpers.last_weekday_of_month(d.year, d.month)
430
+ else
431
+ false
432
+ end
433
+ end
434
+
435
+ # Per-variant next functions
436
+ def self.next_day_repeat(interval, days, times, tz, anchor, now)
437
+ now_local = tz.utc_to_local(now.utc)
438
+ d = now_local.to_date
439
+
440
+ if interval <= 1
441
+ if EvalHelpers.matches_day_filter(d, days)
442
+ candidate = EvalHelpers.earliest_future_at_times(d, times, tz, now)
443
+ return candidate if candidate
444
+ end
445
+
446
+ 8.times do
447
+ d += 1
448
+ if EvalHelpers.matches_day_filter(d, days)
449
+ candidate = EvalHelpers.earliest_future_at_times(d, times, tz, now)
450
+ return candidate if candidate
451
+ end
452
+ end
453
+
454
+ return nil
455
+ end
456
+
457
+ # Interval > 1
458
+ anchor_date = anchor ? Date.parse(anchor) : EPOCH_DATE
459
+ offset = EvalHelpers.days_between(anchor_date, d)
460
+ remainder = offset % interval
461
+ aligned_date = remainder.zero? ? d : d + (interval - remainder)
462
+
463
+ 400.times do
464
+ candidate = EvalHelpers.earliest_future_at_times(aligned_date, times, tz, now)
465
+ return candidate if candidate
466
+
467
+ aligned_date += interval
468
+ end
469
+
470
+ nil
471
+ end
472
+
473
+ def self.next_interval_repeat(interval, unit, from_time, to_time, day_filter, tz, now)
474
+ step_minutes = (unit == IntervalUnit::MIN) ? interval : interval * 60
475
+ from_minutes = (from_time.hour * 60) + from_time.minute
476
+ to_minutes = (to_time.hour * 60) + to_time.minute
477
+
478
+ # Convert now to local time in the target timezone
479
+ now_local = tz.utc_to_local(now.utc)
480
+ d = now_local.to_date
481
+
482
+ 400.times do
483
+ if day_filter && !EvalHelpers.matches_day_filter(d, day_filter)
484
+ d += 1
485
+ next
486
+ end
487
+
488
+ same_day = d == now_local.to_date
489
+ now_minutes = same_day ? (now_local.hour * 60) + now_local.min : -1
490
+
491
+ next_slot = if now_minutes < from_minutes
492
+ from_minutes
493
+ else
494
+ elapsed = now_minutes - from_minutes
495
+ from_minutes + (((elapsed / step_minutes) + 1) * step_minutes)
496
+ end
497
+
498
+ # Try each slot within the day's window until we find one that exists
499
+ # This handles DST gaps where intermediate times don't exist
500
+ while next_slot <= to_minutes
501
+ h = next_slot / 60
502
+ m = next_slot % 60
503
+ candidate = EvalHelpers.at_time_on_date(d, TimeOfDay.new(h, m), tz)
504
+ return candidate if candidate && candidate > now
505
+
506
+ # Slot didn't exist (DST gap) or wasn't in the future, try next slot
507
+ next_slot += step_minutes
508
+ end
509
+
510
+ d += 1
511
+ end
512
+
513
+ nil
514
+ end
515
+
516
+ def self.next_week_repeat(interval, days, times, tz, anchor, now)
517
+ anchor_date = anchor ? Date.parse(anchor) : EPOCH_MONDAY
518
+
519
+ now_local = tz.utc_to_local(now.utc)
520
+ d = now_local.to_date
521
+ sorted_days = days.sort_by { |wd| Weekday.number(wd) }
522
+
523
+ dow_offset = d.cwday - 1
524
+ current_monday = d - dow_offset
525
+
526
+ anchor_dow_offset = anchor_date.cwday - 1
527
+ anchor_monday = anchor_date - anchor_dow_offset
528
+
529
+ 54.times do
530
+ weeks = EvalHelpers.weeks_between(anchor_monday, current_monday)
531
+
532
+ if weeks.negative?
533
+ skip = (-weeks + interval - 1) / interval
534
+ current_monday += skip * interval * 7
535
+ next
536
+ end
537
+
538
+ if (weeks % interval).zero?
539
+ sorted_days.each do |wd|
540
+ day_offset = Weekday.number(wd) - 1
541
+ target_date = current_monday + day_offset
542
+ candidate = EvalHelpers.earliest_future_at_times(target_date, times, tz, now)
543
+ return candidate if candidate
544
+ end
545
+ end
546
+
547
+ remainder = weeks % interval
548
+ skip_weeks = remainder.zero? ? interval : interval - remainder
549
+ current_monday += skip_weeks * 7
550
+ end
551
+
552
+ nil
553
+ end
554
+
555
+ def self.next_month_repeat(interval, target, times, tz, anchor, now)
556
+ now_local = tz.utc_to_local(now.utc)
557
+ year = now_local.year
558
+ month = now_local.month
559
+
560
+ anchor_date = anchor ? Date.parse(anchor) : EPOCH_DATE
561
+ max_iter = (interval > 1) ? 24 * interval : 24
562
+
563
+ max_iter.times do
564
+ if interval > 1
565
+ cur = Date.new(year, month, 1)
566
+ month_offset = EvalHelpers.months_between_ym(anchor_date, cur)
567
+ if month_offset.negative? || (month_offset % interval) != 0
568
+ month += 1
569
+ if month > 12
570
+ month = 1
571
+ year += 1
572
+ end
573
+ next
574
+ end
575
+ end
576
+
577
+ date_candidates = []
578
+
579
+ case target
580
+ when DaysTarget
581
+ expanded = Hron.expand_month_target(target)
582
+ last = EvalHelpers.last_day_of_month(year, month)
583
+ expanded.each do |day_num|
584
+ next unless day_num <= last.day
585
+
586
+ begin
587
+ date_candidates << Date.new(year, month, day_num)
588
+ rescue ArgumentError
589
+ # Invalid date
590
+ end
591
+ end
592
+ when LastDayTarget
593
+ date_candidates << EvalHelpers.last_day_of_month(year, month)
594
+ when LastWeekdayTarget
595
+ date_candidates << EvalHelpers.last_weekday_of_month(year, month)
596
+ end
597
+
598
+ best = nil
599
+ date_candidates.each do |dc|
600
+ candidate = EvalHelpers.earliest_future_at_times(dc, times, tz, now)
601
+ best = candidate if candidate && (best.nil? || candidate < best)
602
+ end
603
+ return best if best
604
+
605
+ month += 1
606
+ if month > 12
607
+ month = 1
608
+ year += 1
609
+ end
610
+ end
611
+
612
+ nil
613
+ end
614
+
615
+ def self.next_ordinal_repeat(interval, ordinal, day, times, tz, anchor, now)
616
+ now_local = tz.utc_to_local(now.utc)
617
+ year = now_local.year
618
+ month = now_local.month
619
+
620
+ anchor_date = anchor ? Date.parse(anchor) : EPOCH_DATE
621
+ max_iter = (interval > 1) ? 24 * interval : 24
622
+
623
+ max_iter.times do
624
+ if interval > 1
625
+ cur = Date.new(year, month, 1)
626
+ month_offset = EvalHelpers.months_between_ym(anchor_date, cur)
627
+ if month_offset.negative? || (month_offset % interval) != 0
628
+ month += 1
629
+ if month > 12
630
+ month = 1
631
+ year += 1
632
+ end
633
+ next
634
+ end
635
+ end
636
+
637
+ ordinal_date = if ordinal == OrdinalPosition::LAST
638
+ EvalHelpers.last_weekday_in_month(year, month, day)
639
+ else
640
+ EvalHelpers.nth_weekday_of_month(year, month, day, OrdinalPosition.to_n(ordinal))
641
+ end
642
+
643
+ if ordinal_date
644
+ candidate = EvalHelpers.earliest_future_at_times(ordinal_date, times, tz, now)
645
+ return candidate if candidate
646
+ end
647
+
648
+ month += 1
649
+ if month > 12
650
+ month = 1
651
+ year += 1
652
+ end
653
+ end
654
+
655
+ nil
656
+ end
657
+
658
+ def self.next_single_date(date_spec, times, tz, now)
659
+ case date_spec
660
+ when IsoDate
661
+ d = Date.parse(date_spec.date)
662
+ EvalHelpers.earliest_future_at_times(d, times, tz, now)
663
+ when NamedDate
664
+ now_local = tz.utc_to_local(now.utc)
665
+ start_year = now_local.year
666
+ 8.times do |y|
667
+ year = start_year + y
668
+ begin
669
+ d = Date.new(year, MonthName.number(date_spec.month), date_spec.day)
670
+ candidate = EvalHelpers.earliest_future_at_times(d, times, tz, now)
671
+ return candidate if candidate
672
+ rescue ArgumentError
673
+ # Invalid date
674
+ end
675
+ end
676
+ nil
677
+ end
678
+ end
679
+
680
+ def self.next_year_repeat(interval, target, times, tz, anchor, now)
681
+ now_local = tz.utc_to_local(now.utc)
682
+ start_year = now_local.year
683
+ anchor_year = anchor ? Date.parse(anchor).year : EPOCH_DATE.year
684
+
685
+ max_iter = (interval > 1) ? 8 * interval : 8
686
+
687
+ max_iter.times do |y|
688
+ year = start_year + y
689
+
690
+ if interval > 1
691
+ year_offset = year - anchor_year
692
+ next if year_offset.negative? || (year_offset % interval) != 0
693
+ end
694
+
695
+ target_date = compute_year_target_date(target, year)
696
+ next unless target_date
697
+
698
+ candidate = EvalHelpers.earliest_future_at_times(target_date, times, tz, now)
699
+ return candidate if candidate
700
+ end
701
+
702
+ nil
703
+ end
704
+
705
+ def self.compute_year_target_date(target, year)
706
+ case target
707
+ when YearDateTarget
708
+ Date.new(year, MonthName.number(target.month), target.day)
709
+ when YearOrdinalWeekdayTarget
710
+ if target.ordinal == OrdinalPosition::LAST
711
+ EvalHelpers.last_weekday_in_month(year, MonthName.number(target.month), target.weekday)
712
+ else
713
+ EvalHelpers.nth_weekday_of_month(year, MonthName.number(target.month), target.weekday,
714
+ OrdinalPosition.to_n(target.ordinal))
715
+ end
716
+ when YearDayOfMonthTarget
717
+ Date.new(year, MonthName.number(target.month), target.day)
718
+ when YearLastWeekdayTarget
719
+ EvalHelpers.last_weekday_of_month(year, MonthName.number(target.month))
720
+ end
721
+ rescue ArgumentError
722
+ nil
723
+ end
724
+ end
725
+ end