acute 1.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.
Files changed (76) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +17 -0
  3. data/data/assumptions.yml +124 -0
  4. data/data/words.yml +699 -0
  5. data/exe/acute +4 -0
  6. data/lib/acute/assumption.rb +13 -0
  7. data/lib/acute/calendar.rb +91 -0
  8. data/lib/acute/chronic.rb +176 -0
  9. data/lib/acute/chronic_duration.rb +321 -0
  10. data/lib/acute/cli.rb +168 -0
  11. data/lib/acute/context.rb +205 -0
  12. data/lib/acute/describe.rb +316 -0
  13. data/lib/acute/duration.rb +120 -0
  14. data/lib/acute/errors.rb +47 -0
  15. data/lib/acute/explain.rb +165 -0
  16. data/lib/acute/extract.rb +493 -0
  17. data/lib/acute/grammar/clock_words.rb +109 -0
  18. data/lib/acute/grammar/compact.rb +222 -0
  19. data/lib/acute/grammar/duration.rb +93 -0
  20. data/lib/acute/grammar/nesting.rb +104 -0
  21. data/lib/acute/grammar/numbers.rb +378 -0
  22. data/lib/acute/grammar/ordinals.rb +101 -0
  23. data/lib/acute/grammar/ranges.rb +112 -0
  24. data/lib/acute/grammar/rule.rb +277 -0
  25. data/lib/acute/grammar/shifts.rb +226 -0
  26. data/lib/acute/grammar/words.rb +307 -0
  27. data/lib/acute/grammar.rb +378 -0
  28. data/lib/acute/holidays.rb +97 -0
  29. data/lib/acute/lexer.rb +487 -0
  30. data/lib/acute/locale.rb +53 -0
  31. data/lib/acute/mcp.rb +224 -0
  32. data/lib/acute/parser.rb +66 -0
  33. data/lib/acute/read.rb +21 -0
  34. data/lib/acute/resolve/clock.rb +75 -0
  35. data/lib/acute/resolve/dates.rb +75 -0
  36. data/lib/acute/resolve/day.rb +24 -0
  37. data/lib/acute/resolve/fuzzy.rb +91 -0
  38. data/lib/acute/resolve/named.rb +116 -0
  39. data/lib/acute/resolve/nth.rb +47 -0
  40. data/lib/acute/resolve/period.rb +160 -0
  41. data/lib/acute/resolve/periodic.rb +274 -0
  42. data/lib/acute/resolve/ranges.rb +165 -0
  43. data/lib/acute/resolve/recur.rb +187 -0
  44. data/lib/acute/resolve/rel.rb +146 -0
  45. data/lib/acute/resolve/shift.rb +81 -0
  46. data/lib/acute/resolve.rb +245 -0
  47. data/lib/acute/result.rb +128 -0
  48. data/lib/acute/rule/cron.rb +222 -0
  49. data/lib/acute/rule/expand.rb +401 -0
  50. data/lib/acute/rule.rb +232 -0
  51. data/lib/acute/tables/assumptions.rb +39 -0
  52. data/lib/acute/tables/words.rb +656 -0
  53. data/lib/acute/tables/zones.rb +930 -0
  54. data/lib/acute/tool/compact.rb +82 -0
  55. data/lib/acute/tool/ruby_llm.rb +29 -0
  56. data/lib/acute/tool.rb +192 -0
  57. data/lib/acute/verify.rb +216 -0
  58. data/lib/acute/version.rb +3 -0
  59. data/lib/acute/zone.rb +252 -0
  60. data/lib/acute.rb +160 -0
  61. data/schema/description.json +12 -0
  62. data/schema/duration.json +26 -0
  63. data/schema/extraction.json +30 -0
  64. data/schema/failure.json +29 -0
  65. data/schema/now.json +17 -0
  66. data/schema/result.json +45 -0
  67. data/schema/rule.json +19 -0
  68. data/schema/tools/describe.json +13 -0
  69. data/schema/tools/duration.json +12 -0
  70. data/schema/tools/extract.json +13 -0
  71. data/schema/tools/now.json +9 -0
  72. data/schema/tools/parse.json +13 -0
  73. data/schema/tools/recurrence.json +12 -0
  74. data/schema/tools/verify.json +14 -0
  75. data/schema/verdict.json +25 -0
  76. metadata +117 -0
@@ -0,0 +1,116 @@
1
+ module Acute
2
+ module Resolve
3
+ # Named periods: parts of the day, weekends, seasons and holidays, each inside the period that holds one
4
+ module Named
5
+ DAY = Period::DAY
6
+ PART_POINT = Assumption.for(:part_of_day_point)
7
+ WEEKEND_FRIDAY = Assumption.for(:weekend_friday_evening)
8
+ SEASON = Assumption.for(:meteorological_season)
9
+
10
+ # D1, D35: start and end in seconds from the day's start, and the point
11
+ PARTS = Ractor.make_shareable({
12
+ morning: [6 * 3600, 12 * 3600, 9 * 3600], afternoon: [12 * 3600, 17 * 3600, 15 * 3600],
13
+ evening: [17 * 3600, 21 * 3600, 19 * 3600], night: [21 * 3600, 28 * 3600, 22 * 3600],
14
+ tonight: [17 * 3600, 28 * 3600, 22 * 3600], lunch: [12 * 3600, 14 * 3600, 12 * 3600 + 1800],
15
+ early_hours: [0, 4 * 3600, 2 * 3600] # D42
16
+ })
17
+ # D3: meteorological, by the month each starts in; the south is six months on
18
+ SEASONS = { spring: 3, summer: 6, autumn: 9, winter: 12 }.freeze
19
+ # D15, D28: each built-in holiday as a formula. [month, day] is a fixed date; [month, day, wday] the first
20
+ # wday on or after it (the third Monday of January is the first from the 15th); [holiday, days] an offset
21
+ # from Easter or another holiday
22
+ HOLIDAYS = Ractor.make_shareable({
23
+ new_years_day: [1, 1], groundhog_day: [2, 2], valentines: [2, 14], st_patricks: [3, 17], april_fools: [4, 1],
24
+ earth_day: [4, 22], flag_day: [6, 14], juneteenth: [6, 19], independence_day: [7, 4], patriot_day: [9, 11],
25
+ bosses_day: [10, 16], halloween: [10, 31], veterans_day: [11, 11], christmas_eve: [12, 24],
26
+ christmas: [12, 25], boxing_day: [12, 26], new_years_eve: [12, 31],
27
+ mlk_day: [1, 15, 1], presidents_day: [2, 15, 1], mothers_day: [5, 8, 0], memorial_day: [5, 25, 1],
28
+ fathers_day: [6, 15, 0], labor_day: [9, 1, 1], columbus_day: [10, 8, 1], thanksgiving: [11, 22, 4],
29
+ us_thanksgiving: [11, 22, 4], black_friday: [:us_thanksgiving, 1], cyber_monday: [:us_thanksgiving, 4],
30
+ easter: [:easter, 0], good_friday: [:easter, -2], easter_monday: [:easter, 1], palm_sunday: [:easter, -7],
31
+ maundy_thursday: [:easter, -3], ash_wednesday: [:easter, -46], mardi_gras: [:easter, -47],
32
+ pentecost: [:easter, 49], whit_monday: [:easter, 50], trinity_sunday: [:easter, 56],
33
+ # D40, D48: the Tuesday after the first Monday; the Wednesday of April's last full week
34
+ election_day: [11, 2, 2], admin_day: [4, 21, 3], grandparents_day: [:labor_day, 6],
35
+ military_spouse_day: [:mothers_day, -2], lincolns_birthday: [2, 12], law_day: [5, 1], loyalty_day: [5, 1],
36
+ may_day: [5, 1], workers_day: [5, 1], siblings_day: [4, 10], world_vegan_day: [11, 1], kwanzaa: [12, 26],
37
+ ides_of_march: [3, 15], leap_day: [2, 29]
38
+ })
39
+ # D48: a holiday of several days names its first, then all of them
40
+ LASTS = { kwanzaa: 7 }.freeze
41
+ # D15, D27: the holidays a country has its own way, or alone
42
+ GB_IE = { mothers_day: [:easter, -21], mothering_sunday: [:easter, -21], may_day: [5, 1, 1] }.freeze
43
+ LOCAL = Ractor.make_shareable({
44
+ "CA" => { thanksgiving: [10, 8, 1], canada_day: [7, 1], victoria_day: [5, 18, 1], vimy_ridge_day: [4, 9] },
45
+ "AU" => { australia_day: [1, 26], anzac_day: [4, 25], melbourne_cup: [11, 1, 2] },
46
+ "NZ" => { anzac_day: [4, 25] }, "GB" => { **GB_IE, guy_fawkes: [11, 5] }, "IE" => GB_IE,
47
+ "ZA" => { reconciliation_day: [12, 16], kruger_day: [10, 10] }, "PH" => { rizal_day: [12, 30] },
48
+ "TT" => { indian_arrival_day: [5, 30] }, "BZ" => { garifuna_day: [11, 19] }
49
+ })
50
+
51
+ def self.part(name, day, ctx)
52
+ b, e, point = PARTS.fetch(name)
53
+ ctx.note(PART_POINT)
54
+ Span.new(day * DAY + b, day * DAY + e, :hour, day * DAY + point)
55
+ end
56
+
57
+ # D8: Saturday 00:00 to Monday 00:00, then from Friday 18:00
58
+ def self.weekends(week, ctx)
59
+ sat = week.b + ((6 - ctx.week_start) % 7) * DAY
60
+ ctx.note(WEEKEND_FRIDAY)
61
+ [Span.new(sat, sat + 2 * DAY, :weekend, nil), Span.new(sat - 6 * 3600, sat + 2 * DAY, :weekend, nil)]
62
+ end
63
+
64
+ # D48: "labor day weekend": the weekend nearest the holiday, the one before from Monday to Wednesday, and the
65
+ # holiday with it when it is a Monday or Friday
66
+ def self.long_weekend(h, ctx)
67
+ d = h.b / DAY
68
+ wd = (d + 4) % 7
69
+ sat = (d + (wd == 0 ? -1 : wd <= 3 ? -1 - wd : 6 - wd)) * DAY
70
+ e = [sat + 2 * DAY, h.e].max
71
+ return [Span.new(h.b, e, :weekend, nil)] if h.b < sat
72
+ ctx.note(WEEKEND_FRIDAY)
73
+ [Span.new(sat, e, :weekend, nil), Span.new(sat - 6 * 3600, e, :weekend, nil)]
74
+ end
75
+
76
+ # D3, D33: a season belongs to the year it starts in
77
+ def self.season(name, year, ctx)
78
+ m = SEASONS.fetch(name)
79
+ m = (m + 5) % 12 + 1 if ctx.south
80
+ ctx.note(SEASON)
81
+ b = Period.civil(year, m, 1)
82
+ Span.new(b, Period.add(b, 3, :month), :season, nil)
83
+ end
84
+
85
+ # D15, D28: the holiday in the year, or nil when the locale has none of that name. D47: a holidays: given
86
+ # is asked first
87
+ def self.holiday(name, year, ctx)
88
+ if (given = ctx.holidays) && (date = given.named(name, year))
89
+ d = date.jd - 2_440_588
90
+ return Span.new(d * DAY, d * DAY + DAY, :day, nil)
91
+ end
92
+ if name == :leap_year # D48: named like a holiday, the whole of a year with a 29 February
93
+ return unless Calendar.leap?(year)
94
+ return Span.new(Period.civil(year, 1, 1), Period.civil(year + 1, 1, 1), :year, nil)
95
+ end
96
+ f = (own = LOCAL[ctx.locale.country]) && own[name] || HOLIDAYS[name]
97
+ return unless f
98
+ return if f[1] == 29 && f[0] == 2 && !Calendar.leap?(year) # D48: no leap day in a common year
99
+ d = day_of(f, year)
100
+ Span.new(d * DAY, d * DAY + DAY, :day, nil)
101
+ end
102
+
103
+ def self.day_of(f, year)
104
+ a, b, wday = f
105
+ if Integer === a
106
+ wday ? nth_weekday(Calendar.days_from_civil(year, a, b), wday, 1) : Calendar.days_from_civil(year, a, b)
107
+ elsif a == :easter then Calendar.easter(year) + b
108
+ else day_of(HOLIDAYS.fetch(a), year) + b
109
+ end
110
+ end
111
+
112
+ # The day number of the nth wday on or after the day first; (first + 4) % 7 is its weekday
113
+ def self.nth_weekday(first, wday, n) = first + (wday - (first + 4) % 7) % 7 + 7 * (n - 1)
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,47 @@
1
+ module Acute
2
+ module Resolve
3
+ module Periodic
4
+ # "second tuesday of march", "last friday of the month", "Q3", "first day of next month"
5
+ def self.nth(node, s, ctx)
6
+ n = node.n
7
+ x = node.node
8
+ out = case x
9
+ when G::Weekday
10
+ # the nth wday from the first day, or the last one back from the last day; (d + 4) % 7 is d's weekday
11
+ if n > 0
12
+ d = s.b / DAY
13
+ d += (x.wday - (d + 4) % 7) % 7 + 7 * (n - 1)
14
+ else
15
+ d = s.e / DAY - 1
16
+ d -= (d + 4 - x.wday) % 7 - 7 * (n + 1) # D48: "second last friday"
17
+ end
18
+ b = d * DAY
19
+ return b >= s.b && b + DAY <= s.e ? [Span.new(b, b + DAY, :day, nil)] : []
20
+ when G::Weekend
21
+ if n > 0
22
+ sat = Named.nth_weekday(Period.day(s.b), 6, n)
23
+ else
24
+ sat = s.e / DAY - 1
25
+ sat -= (sat - 2) % 7 - 7 * (n + 1) # (sat + 4 - 6) % 7: days back to a Saturday
26
+ end
27
+ # D44: the weekend whose Saturday is in the period, though it ends after it
28
+ return [] unless sat * DAY >= s.b && sat * DAY < s.e
29
+ return Named.weekends(Period.span(:week, ctx, Period.index(:week, ctx, sat * DAY)), ctx)
30
+ else
31
+ unit = x.unit
32
+ if unit == :business_day # D44: the nth weekday, or the last
33
+ b = n > 0 ? Period.business(s.b - DAY, n, ctx) : Period.business(s.e, n, ctx)
34
+ [Span.new(b, b + DAY, :day, nil)]
35
+ elsif Period::DAYS[unit]
36
+ b = n > 0 ? s.b + (n - 1) * Period::DAYS[unit] * DAY : s.e + n * Period::DAYS[unit] * DAY
37
+ [Span.new(b, b + Period::DAYS[unit] * DAY, unit, nil)]
38
+ else
39
+ b = n > 0 ? Period.add(s.b, n - 1, unit) : Period.add(s.e, n, unit)
40
+ [Span.new(b, Period.add(b, 1, unit), unit, nil)]
41
+ end
42
+ end
43
+ out.select { |o| o.b >= s.b && o.e <= s.e }
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,160 @@
1
+ module Acute
2
+ module Resolve
3
+ # Calendar periods and unit arithmetic on local wall seconds
4
+ module Period
5
+ DAY = 86_400
6
+ SECONDS = { second: 1, minute: 60, hour: 3600 }.freeze
7
+ MONTHS = { month: 1, quarter: 3, season: 3, year: 12, decade: 120, century: 1200 }.freeze
8
+ DAYS = { day: 1, week: 7, fortnight: 14 }.freeze
9
+
10
+ def self.day(t) = t / DAY
11
+
12
+ def self.ymd(t) = Calendar.civil_from_days(t / DAY)
13
+
14
+ def self.year(t) = Calendar.year_of(t / DAY)
15
+
16
+ def self.civil(y, m, d) = Calendar.days_from_civil(y, m, d) * DAY
17
+
18
+ # Months since year 0, so a month is one Integer
19
+ def self.month_index(t)
20
+ p = ymd(t)
21
+ (p / 10_000) * 12 + p / 100 % 100 - 1
22
+ end
23
+
24
+ def self.month_start(i) = civil(i / 12, i % 12 + 1, 1)
25
+
26
+ # The first day (a day number) of the week holding day d, for a week starting on week_start
27
+ def self.week_first(d, week_start) = d - (Calendar.weekday(d) - week_start) % 7
28
+
29
+ # A cycle is how often a periodic phrase repeats: :day, :week, :month or :year. Index i counts cycles.
30
+ def self.index(cycle, ctx, t)
31
+ case cycle
32
+ when :day then day(t)
33
+ when :week then (day(t) - ctx.week_start + 4) / 7
34
+ when :month then month_index(t)
35
+ else Calendar.year_of(t / DAY)
36
+ end
37
+ end
38
+
39
+ def self.span(cycle, ctx, i)
40
+ case cycle
41
+ when :day then Span.new(i * DAY, (i + 1) * DAY, :day, nil)
42
+ when :week
43
+ b = (i * 7 + ctx.week_start - 4) * DAY
44
+ Span.new(b, b + 7 * DAY, :week, nil)
45
+ when :month then Span.new(month_start(i), month_start(i + 1), :month, nil)
46
+ else
47
+ y = i - 1 # Calendar.year_start, inline
48
+ b = 365 * y + y / 4 - y / 100 + y / 400 - 719_162
49
+ Span.new(b * DAY, (b + (i % 4 == 0 && (i % 100 != 0 || i % 400 == 0) ? 366 : 365)) * DAY, :year, nil)
50
+ end
51
+ end
52
+
53
+ # The calendar unit holding t
54
+ def self.unit(unit, ctx, t)
55
+ case unit
56
+ when :second, :minute, :hour
57
+ len = SECONDS[unit]
58
+ b = t - t % len
59
+ Span.new(b, b + len, unit, nil)
60
+ when :day, :business_day then span(:day, ctx, day(t))
61
+ when :week, :fortnight then span(:week, ctx, index(:week, ctx, t))
62
+ when :month then span(:month, ctx, month_index(t))
63
+ else
64
+ n = MONTHS[unit]
65
+ i = month_index(t)
66
+ i -= unit == :season ? (i + 1) % 3 : i % n # D33: seasons start in December, March, June and September
67
+ Span.new(month_start(i), month_start(i + n), unit, nil)
68
+ end
69
+ end
70
+
71
+ # t moved by n units (D23: Temporal arithmetic, so jan 31 + 1 month is feb 28). ctx only for business days.
72
+ def self.add(t, n, unit, ctx = nil)
73
+ if (s = SECONDS[unit]) then t + n * s
74
+ elsif (d = DAYS[unit]) then t + n * d * DAY
75
+ elsif unit == :business_day then business(t, n, ctx)
76
+ else
77
+ p = ymd(t)
78
+ y = p / 10_000
79
+ m = p / 100 % 100
80
+ i = y * 12 + m - 1 + n * MONTHS[unit]
81
+ y = i / 12
82
+ m = i % 12 + 1
83
+ d = p % 100
84
+ last = Calendar.days_in_month(y, m)
85
+ civil(y, m, d < last ? d : last) + t % DAY
86
+ end
87
+ end
88
+
89
+ LIMIT = 10_000
90
+ class Limit < StandardError; end
91
+
92
+ # D49: what one call may still count, shared by every count in it (T17): LIMIT business days in all, and
93
+ # LIMIT days a business_days: calendar says aren't. It holds the calendar too, so a holidays-gem year is
94
+ # looked up once a call.
95
+ class Budget
96
+ attr_accessor :days, :skips, :workdays
97
+
98
+ def initialize
99
+ @days = @skips = LIMIT
100
+ end
101
+ end
102
+
103
+ # D17: Monday to Friday, or D47: the days business_days: or holidays: give; D38: the time of day is kept.
104
+ # D49: past the call's Budget is a failure.
105
+ def self.business(t, n, ctx)
106
+ return t if n == 0
107
+ budget = ctx.budget
108
+ raise Limit if n.abs > budget.days
109
+ budget.days -= n.abs
110
+ d = t / DAY
111
+ days = ctx.business_days
112
+ to = case days
113
+ when nil then Calendar.add_weekdays(d, n)
114
+ when Holidays::RegionDays then days.add(d, n)
115
+ else step(d, n, days, budget)
116
+ end
117
+ t + (to - d) * DAY
118
+ end
119
+
120
+ # A business_days: calendar, or holidays: that only answer holiday?, are asked day by day
121
+ def self.step(d, n, days, budget)
122
+ step = n < 0 ? -1 : 1
123
+ n.abs.times do
124
+ d += step
125
+ until days.business_day?(::Date.jd(d + 2_440_588))
126
+ raise Limit if (budget.skips -= 1) < 0
127
+ d += step
128
+ end
129
+ end
130
+ d
131
+ end
132
+
133
+ # D40: "half a year" is 6 months; a fraction becomes a whole count of the next smaller unit
134
+ SMALLER = Ractor.make_shareable({ year: [:month, 12], quarter: [:month, 3], season: [:month, 3], month: [:day, 30], fortnight: [:day, 14],
135
+ week: [:day, 7], day: [:hour, 24], hour: [:minute, 60], minute: [:second, 60] })
136
+
137
+ def self.whole(count, unit)
138
+ while count.is_a?(Rational) && count.denominator != 1 && (smaller = SMALLER[unit])
139
+ unit, k = smaller
140
+ count *= k
141
+ end
142
+ [count.to_i, unit]
143
+ end
144
+
145
+ # D17, D38: only with fiscal_year_start:, and FY27 is the year ending in 2027; "next fiscal year" is the
146
+ # one after the fiscal year holding today
147
+ def self.fiscal(node, ctx, shift = nil)
148
+ start = ctx.fiscal_year_start
149
+ return [] unless start
150
+ if shift
151
+ ym = ymd(ctx.now_local) / 100
152
+ y = ym / 100 + shift - (ym % 100 < start ? 1 : 0)
153
+ else
154
+ y = start == 1 ? node.year : node.year - 1
155
+ end
156
+ Span.new(civil(y, start, 1), civil(y + 1, start, 1), :year, nil).then { [_1] }
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,274 @@
1
+ module Acute
2
+ module Resolve
3
+ # Phrases that repeat: "friday" weekly, "the 3rd" monthly, "march 3" and "christmas" yearly, "tonight"
4
+ # daily. Each is found as an occurrence in one cycle (a week, month, year or day), so every rule about
5
+ # which occurrence is meant (D11, D20, D21, D32, D40) is written once, here and in rel.rb.
6
+ module Periodic
7
+ DAY = Period::DAY
8
+ LIMIT = 12 # cycles to search; enough for "feb 29"
9
+ SAME_DAY = Assumption.for(:same_day_or_next)
10
+ SAME_DAY_PASSED = Assumption.for(:same_day_passed)
11
+ SEASON_YEAR = Assumption.for(:season_year)
12
+ SAME_OR_NEXT = [0, DAY].freeze
13
+ SUB_DAY = { G::Clock => true, G::Part => true, G::Range => true, G::Fuzzy => true }.freeze # "friday early evening"
14
+
15
+ def self.cycle(node)
16
+ case G::KIND[node.class]
17
+ when :weekday, :weekend then :week
18
+ when :date then node.year || node.sep ? nil : node.month ? :year : :month
19
+ when :holiday, :season, :iso_week then :year
20
+ when :part, :clock then :day
21
+ when :nth then G::Unit === node.node && (node.node.unit == :quarter || node.node.unit == :month) ? :year : :month
22
+ when :within then cycle(node.outer)
23
+ when :fuzzy then cycle(node.node)
24
+ end
25
+ end
26
+
27
+ # The occurrences in cycle i, the main one first; empty when there is none ("feb 29" in 2027)
28
+ def self.at(node, ctx, i, cyc = cycle(node))
29
+ inside(node, nil, Period.span(cyc, ctx, i), ctx)
30
+ end
31
+
32
+ # D11, D23: the current one if it isn't over, else the next; in past context the latest that has begun
33
+ def self.bare(node, ctx, cyc = cycle(node))
34
+ return [] unless cyc
35
+ now = ctx.now_local
36
+ i = Period.index(cyc, ctx, now)
37
+ # The cycles either side of now's are looked at only when an occurrence there can reach into it
38
+ near = contained?(node) ? 0 : 1
39
+ # The weekday or day of the month, under any time or part of day; (today + 4) % 7 is today's weekday
40
+ if cyc == :week || cyc == :month
41
+ day = node
42
+ day = day.outer while G::Within === day && SUB_DAY[day.inner.class]
43
+ today = cyc == :week ? G::Weekday === day && (ctx.today + 4) % 7 == day.wday :
44
+ G::Date === day && day.day == Calendar.civil_from_days(ctx.today) % 100
45
+ return on_that_day(node, ctx, i, cyc) if today && ctx.past != :back
46
+ # A weekday already gone this week (or, looking back, still to come) can't hold the occurrence
47
+ if near == 0 && !today && G::Weekday === day
48
+ w = i * 7 + ctx.week_start - 4 # the week's first day
49
+ d = w + (day.wday - w - 4) % 7
50
+ i += ctx.past? ? (d > ctx.today ? -1 : 0) : (d < ctx.today ? 1 : 0)
51
+ end
52
+ elsif cyc == :year && near == 0
53
+ # The same for a month already gone this year
54
+ d = node
55
+ d = d.outer while G::Within === d
56
+ if G::Date === d && (m = d.month)
57
+ cur = Period.ymd(now) / 100 % 100
58
+ i += ctx.past? ? (m > cur ? -1 : 0) : (m < cur ? 1 : 0)
59
+ end
60
+ end
61
+ if ctx.past?
62
+ list = last(node, ctx, i + near, cyc) { |s| s.b <= now }
63
+ # D44: in past context, a period under way is the current one, then the one before
64
+ if ctx.past == true && !list.empty? && list[0].e > now
65
+ b = list[0].b
66
+ list += last(node, ctx, Period.index(cyc, ctx, b) - 1, cyc) { |s| s.b < b }.take(1)
67
+ end
68
+ return list
69
+ end
70
+ list = first(node, ctx, i - near, cyc) { |s| s.e > now }
71
+ # D40, D41: "tonight" said at exactly 00:00 is the rest of the night under way, then the coming one
72
+ if now % DAY == 0 && G::Part === node && node.name == :tonight && !list.empty? && list[0].b < now
73
+ return [Span.new(now, list[0].e, list[0].grain, nil), *at(node, ctx, i, cyc)]
74
+ end
75
+ list
76
+ end
77
+
78
+ # D40, D44: a weekday or a day of the month said on that day, with or without a time: today's, then the
79
+ # next one (in past context the one before); today's comes second once it is over
80
+ def self.on_that_day(node, ctx, i, cyc)
81
+ cur = at(node, ctx, i, cyc)
82
+ if ctx.past?
83
+ other = last(node, ctx, i - 1, cyc) { true }.take(1)
84
+ return other if cur.empty?
85
+ today_first = cur[0].b <= ctx.now_local
86
+ else
87
+ other = first(node, ctx, i + 1, cyc) { true }
88
+ return other if cur.empty?
89
+ other = other.take(1) if cur.size == 1
90
+ today_first = cur[0].e > ctx.now_local
91
+ end
92
+ ctx.note(today_first ? SAME_DAY : SAME_DAY_PASSED)
93
+ today_first ? cur + other : other + cur
94
+ end
95
+
96
+ # The first occurrence from cycle i on whose main reading passes the block
97
+ # A while loop: a return from inside a block would unwind through it, which costs more than the search
98
+ def self.first(node, ctx, i, cyc = cycle(node), &)
99
+ stop = i + LIMIT
100
+ while i < stop
101
+ list = at(node, ctx, i, cyc)
102
+ return list if !list.empty? && yield(list[0])
103
+ return [] if list.empty? && !skips?(node) && !passed_over?(node, ctx, i, cyc, &)
104
+ i += 1
105
+ end
106
+ []
107
+ end
108
+
109
+ # Whether every occurrence lies inside its own cycle
110
+ def self.contained?(node)
111
+ case G::KIND[node.class]
112
+ when :weekday, :date, :holiday, :nth then true
113
+ when :clock then node.named != :midnight
114
+ when :within then contained?(node.inner) && contained?(node.outer)
115
+ else false
116
+ end
117
+ end
118
+
119
+ # D42: only a date skips the cycles it doesn't exist in ("feb 29", "the 31st"); "5th tuesday in may" is
120
+ # nil, not a later May that has one
121
+ def self.skips?(node) = G::Date === node || G::IsoWeek === node || (G::Within === node && skips?(node.inner)) ||
122
+ (G::Holiday === node && LEAP[node.name]) # D48
123
+ LEAP = { leap_day: true, leap_year: true }.freeze
124
+
125
+ # D37: a weekday with a date filters the date's own occurrence, so a cycle whose date falls on another
126
+ # weekday is passed over only when the date's occurrence there would be
127
+ def self.passed_over?(node, ctx, i, cyc)
128
+ date = unfiltered(node)
129
+ return false if date.equal?(node)
130
+ list = inside(date, nil, Period.span(cyc, ctx, i), ctx)
131
+ !list.empty? && !yield(list[0])
132
+ end
133
+
134
+ def self.unfiltered(node)
135
+ return node unless G::Within === node
136
+ return node.outer if G::Weekday === node.inner && G::Date === node.outer
137
+ outer = unfiltered(node.outer)
138
+ outer.equal?(node.outer) ? node : G::Within.new(node.inner, outer)
139
+ end
140
+
141
+ # The latest occurrence at or before cycle i whose main reading passes the block
142
+ def self.last(node, ctx, i, cyc = cycle(node), &)
143
+ stop = i - LIMIT
144
+ while i > stop
145
+ list = at(node, ctx, i, cyc)
146
+ return list if !list.empty? && yield(list[0])
147
+ return [] if list.empty? && !skips?(node) && !passed_over?(node, ctx, i, cyc, &)
148
+ i -= 1
149
+ end
150
+ []
151
+ end
152
+
153
+ # The readings of node inside the span s; outer is the node s came from, which a clock needs to see a
154
+ # part of day
155
+ def self.inside(node, outer, s, ctx)
156
+ case G::KIND[node.class]
157
+ when :within
158
+ outers = inside(node.outer, outer, s, ctx)
159
+ return inside(node.inner, node.outer, outers[0], ctx) if outers.size == 1
160
+ outers.flat_map { |o| inside(node.inner, node.outer, o, ctx) }
161
+ when :clock then clock(node, outer, s, ctx)
162
+ when :weekday
163
+ d = s.b / DAY
164
+ d += (node.wday - (d + 4) % 7) % 7 # (d + 4) % 7: d's weekday
165
+ d * DAY < s.e ? [Span.new(d * DAY, d * DAY + DAY, :day, nil)] : []
166
+ when :date then date(node, s, ctx)
167
+ when :part then [Named.part(node.name, Period.day(s.b), ctx)]
168
+ when :weekend
169
+ return Named.long_weekend(s, ctx) if G::Holiday === outer
170
+ week = s.grain == :week ? s : Period.span(:week, ctx, Period.index(:week, ctx, s.b))
171
+ Named.weekends(week, ctx)
172
+ when :holiday
173
+ return [] unless (h = Named.holiday(node.name, Period.year(s.b), ctx))
174
+ list = within(h, s)
175
+ n = Named::LASTS[node.name]
176
+ n && !list.empty? ? [*list, Span.new(h.b, h.b + n * DAY, :day, nil)] : list
177
+ when :season
178
+ y = Period.year(s.b)
179
+ list = within(Named.season(node.name, y, ctx), s)
180
+ return list unless G::Date === outer && s.grain == :year
181
+ # D3: "winter 2026" is the one starting in 2026, then the one ending in it
182
+ before = Named.season(node.name, y - 1, ctx)
183
+ return list unless before.e > s.b
184
+ ctx.note(SEASON_YEAR)
185
+ list << before
186
+ when :nth then nth(node, s, ctx)
187
+ when :iso_week then iso_week(node, Period.year(s.b))
188
+ when :range then Ranges.inside(node, outer, s, ctx)
189
+ when :fuzzy then inside(node.node, outer, s, ctx).flat_map { |x| Fuzzy.apply(node.pos, x, node.node, ctx) }
190
+ else Resolve.spans(node, ctx).select { |x| x.b >= s.b && x.b < s.e }
191
+ end
192
+ end
193
+
194
+ # D13: the ISO week of year y, which may start in December before it; week 53 only in a year that has one
195
+ def self.iso_week(node, y)
196
+ d = Calendar.iso_week_one(y) + 7 * (node.week - 1)
197
+ return [] if node.week == 53 && d == Calendar.iso_week_one(y + 1)
198
+ return [Span.new(d * DAY, d * DAY + 7 * DAY, :week, nil)] unless node.day
199
+ d += node.day - 1
200
+ [Span.new(d * DAY, d * DAY + DAY, :day, nil)]
201
+ end
202
+
203
+ def self.within(x, s) = x.b >= s.b && x.b < s.e ? [x] : []
204
+
205
+ def self.date(node, s, ctx)
206
+ return Dates.orders(node, ctx).flat_map { |d| date(d, s, ctx) } if node.sep
207
+ # A year from January 1st (a cycle, not a fiscal year) needs no civil date
208
+ if s.grain == :year && (y = Calendar.year_if_first(s.b / DAY))
209
+ y = node.year || y
210
+ m = node.month || 1
211
+ else
212
+ p = Period.ymd(s.b)
213
+ y = node.year || p / 10_000
214
+ m = node.month || p / 100 % 100
215
+ end
216
+ if node.day
217
+ return [] unless m >= 1 && m <= 12 && node.day >= 1 && (node.day <= 28 || node.day <= Calendar.days_in_month(y, m))
218
+ b = Calendar.days_from_civil(y, m, node.day) * DAY
219
+ b >= s.b && b < s.e ? [Span.new(b, b + DAY, :day, nil)] : []
220
+ elsif node.month
221
+ return [] unless m >= 1 && m <= 12
222
+ b = Calendar.days_from_civil(y, m, 1) * DAY
223
+ b >= s.b && b < s.e ? [Span.new(b, b + (m == 2 ? Calendar.days_in_month(y, m) : Calendar::MONTH_DAYS[m]) * DAY, :month, nil)] : []
224
+ else
225
+ within(Span.new(Calendar.year_start(y) * DAY, Calendar.year_start(y + 1) * DAY, :year, nil), s)
226
+ end
227
+ end
228
+
229
+ # D4, D6 for a clock inside a day; D35, D40 inside a part of the day
230
+ def self.clock(c, outer, s, ctx)
231
+ cands = Clock.candidates(c, ctx, true)
232
+ grain, len = Clock.grain(c)
233
+ day = s.b - s.b % DAY
234
+ part = part_name(outer)
235
+ # D6: midnight "at the start of" a day is its first moment, "at the end of" its last
236
+ cands = [outer.pos == :start ? 0 : 86_400] if c.named == :midnight && G::Fuzzy === outer && (outer.pos == :start || outer.pos == :end)
237
+ unless part
238
+ return [Span.new(day + cands[0], day + cands[0] + len, grain, nil)] if cands.size == 1
239
+ return cands.map { |secs| Span.new(day + secs, day + secs + len, grain, nil) }
240
+ end
241
+ if c.hour == 12 && !c.meridiem && !c.named # D30, D40: 12 in the morning, 12 at night
242
+ secs = c.min * 60 + c.sec
243
+ cands = [secs + (part == :morning ? 0 : part == :afternoon || part == :lunch ? 43_200 : 86_400)]
244
+ elsif !c.meridiem && part != :night && part != :tonight && part != :lunch
245
+ # D40: the part fixes am or pm, even for a time written with seconds; D35: lunch takes the closer
246
+ pm = part != :morning && part != :early_hours
247
+ cands = [c.hour * 3600 + c.min * 60 + c.sec + (pm ? 43_200 : 0)] if !c.h24 && c.hour >= 1 && c.hour <= 11
248
+ fixed = cands.select { |x| (x >= 43_200) == pm }
249
+ cands = fixed unless fixed.empty?
250
+ end
251
+ best = nil
252
+ best_gap = nil
253
+ cands.each do |secs|
254
+ SAME_OR_NEXT.each do |k| # the next day too, for a part that runs past midnight
255
+ t = day + k + secs
256
+ gap = t < s.b ? s.b - t : t >= s.e ? t - s.e : -1
257
+ next if best_gap && gap >= best_gap
258
+ best = t
259
+ best_gap = gap
260
+ end
261
+ end
262
+ [Span.new(best, best + len, grain, nil)]
263
+ end
264
+
265
+ def self.part_name(node)
266
+ case node
267
+ when G::Part then node.name
268
+ when G::Rel, G::Fuzzy then part_name(node.node)
269
+ when G::Within then part_name(node.inner)
270
+ end
271
+ end
272
+ end
273
+ end
274
+ end