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,165 @@
1
+ module Acute
2
+ module Resolve
3
+ # Ranges ("monday to friday", "from 3 to 5pm") and open ends ("after friday", "since monday")
4
+ module Ranges
5
+ DAY = Period::DAY
6
+ UNDER_WAY = Assumption.for(:range_under_way)
7
+
8
+ def self.spans(node, ctx)
9
+ from = node.from
10
+ to = node.to
11
+ return clocks(meridiem(from, to), to, ctx) if G::Clock === from && G::Clock === to
12
+ return numeric(node, ctx) if G::Date === from && G::Date === to && from.sep && from.sep == to.sep
13
+ f = Resolve.spans(from, ctx)[0]
14
+ return [] unless f
15
+ # A fixed end gives a range per reading: "through midnight today" to today's end, then its start (D6)
16
+ ends = G::Clock === to || Periodic.cycle(to) ? [after(to, ctx, f.b)] : Resolve.spans(to, ctx)
17
+ list = ends.filter_map { _1 && finish(f, _1) }
18
+ under_way(from, to, f, list, ctx) if !ctx.past? && Periodic.cycle(from) == :year
19
+ list
20
+ end
21
+
22
+ # D44: a range of dates or months under way ("may to september" in june) is the current one, then the
23
+ # coming one
24
+ def self.under_way(from, to, f, list, ctx)
25
+ now = ctx.now_local
26
+ if f.b > now
27
+ b = Periodic.last(from, ctx, Period.index(:year, ctx, now), :year) { |s| s.b <= now }[0]
28
+ cur = b && (t = after(to, ctx, b.b)) && finish(b, t)
29
+ return unless cur && cur.e > now
30
+ list.unshift(cur)
31
+ else
32
+ # one that starts today is today's alone, as a date said on its day is
33
+ return unless list.size == 1 && list[0].e > now && f.b < ctx.today * DAY
34
+ n = Periodic.first(from, ctx, Period.index(:year, ctx, f.b) + 1, :year) { true }[0]
35
+ coming = n && (t = after(to, ctx, n.b)) && finish(n, t)
36
+ return unless coming
37
+ list << coming
38
+ end
39
+ ctx.note(UNDER_WAY)
40
+ end
41
+
42
+ # D10: both ends of "5/1-5/7" are read in the same order, the locale's first
43
+ def self.numeric(node, ctx)
44
+ out = []
45
+ [false, true].each do |swap|
46
+ f = Dates.order(node.from, ctx, swap)
47
+ t = Dates.order(node.to, ctx, swap)
48
+ next unless f && t
49
+ spans(G::Range.new(f, t), ctx).each { |x| out << x unless out.any? { |o| o.b == x.b && o.e == x.e } }
50
+ end
51
+ ctx.note(Dates::ORDER) if out.size > 1
52
+ out
53
+ end
54
+
55
+ def self.inside(node, outer, s, ctx)
56
+ from = G::Clock === node.from && G::Clock === node.to ? meridiem(node.from, node.to) : node.from
57
+ f = Periodic.inside(from, outer, s, ctx)[0]
58
+ return [] unless f
59
+ t = G::Clock === node.to ? after(node.to, ctx, f.b) : Periodic.inside(node.to, outer, s, ctx).find { |x| x.b >= f.b }
60
+ # D48: a weekday earlier in the week than the start is the one after it, out of the week
61
+ t ||= after(node.to, ctx, f.b) if G::Weekday === node.from && G::Weekday === node.to
62
+ (x = t && finish(f, t)) ? [x] : []
63
+ end
64
+
65
+ # "2 to 4pm" is 2pm; "11 to 2pm" is 11am
66
+ def self.meridiem(from, to)
67
+ return from if from.meridiem || from.named || !to.meridiem || from.hour > 12
68
+ same = from.hour % 12 < to.hour % 12 || (from.hour % 12 == to.hour % 12 && from.min < to.min)
69
+ G::Clock.new(from.hour, from.min, from.sec, same ? to.meridiem : (to.meridiem == :pm ? :am : :pm), nil,
70
+ false, from.seconds)
71
+ end
72
+
73
+ # D40: a clock range under way is today's, one answer, or yesterday's when it runs past midnight (D44);
74
+ # otherwise the next one (the previous in past context)
75
+ def self.clocks(from, to, ctx)
76
+ now = ctx.now_local
77
+ f = Periodic.inside(from, nil, Period.span(:day, ctx, ctx.today), ctx)[0]
78
+ span = Span.new(f.b, clock_after(to, ctx, f.b), f.grain, nil)
79
+ if span.b > now && span.e - DAY > now
80
+ span = Span.new(span.b - DAY, span.e - DAY, span.grain, nil)
81
+ elsif ctx.past? ? span.b > now : span.e <= now
82
+ k = ctx.past? ? -DAY : DAY
83
+ span = Span.new(span.b + k, span.e + k, span.grain, nil)
84
+ end
85
+ [span]
86
+ end
87
+
88
+ # D9: days and larger include the end; with a clock the end is the stated time
89
+ # An end's exact UTC is kept, so "1am EDT – 1:30am EST" stays on each side of the repeated hour, and
90
+ # "1:50am EDT – 1:10am EST" runs forwards
91
+ def self.finish(f, t)
92
+ clock = clock?(t)
93
+ e = clock ? t.b : t.e
94
+ ue = clock ? t.ub : t.ue
95
+ forwards = f.ub && ue ? ue > f.ub : e > f.b
96
+ forwards ? Span.new(f.b, e, f.grain, nil, f.ub, ue) : nil
97
+ end
98
+
99
+ def self.clock?(s) = s.grain == :minute || s.grain == :second
100
+
101
+ # The first reading of node from t on
102
+ def self.after(node, ctx, t)
103
+ if G::Clock === node
104
+ grain, len = Clock.grain(node)
105
+ return Span.new(x = clock_after(node, ctx, t), x + len, grain, nil)
106
+ end
107
+ if (cyc = Periodic.cycle(node))
108
+ return Periodic.first(node, ctx, Period.index(cyc, ctx, t) - 1, cyc) { |s| s.b >= t }[0]
109
+ end
110
+ Resolve.spans(node, ctx)[0]
111
+ end
112
+
113
+ # The earliest reading of a clock after t, over every am/pm reading
114
+ def self.clock_after(c, ctx, t)
115
+ d = Period.day(t) * DAY
116
+ best = nil
117
+ Clock.candidates(c, ctx, true).each do |secs|
118
+ [d + secs, d + DAY + secs].each { |x| best = x if x > t && (best.nil? || x < best) }
119
+ end
120
+ best
121
+ end
122
+
123
+ # D16, D34: since closes at now and looks back; until opens at now; before ends when X starts; after
124
+ # starts when X ends, or at a clock time itself
125
+ def self.open(node, ctx)
126
+ now = ctx.now_local
127
+ past = ctx.past
128
+ ctx.past = node.dir == :since && :back
129
+ list = Resolve.spans(node.node, ctx)
130
+ ctx.past = past
131
+ list = current(node.node, ctx) || list if node.dir == :after && G::Holiday === node.node
132
+ list.map do |s|
133
+ case node.dir
134
+ when :since then Span.new(s.b, now, s.grain, nil, s.ub, ctx.now)
135
+ when :until
136
+ # D44: until the day's last second ("by EOD") is until the day's end
137
+ if clock?(s) && !(s.grain == :second && s.e % DAY == 0) then Span.new(now, s.b, s.grain, nil, ctx.now, s.ub)
138
+ else Span.new(now, s.e, s.grain, nil, ctx.now, s.ue)
139
+ end
140
+ when :upto then Span.new(now, s.b, s.grain, nil, ctx.now, s.ub) # D9: "up to <date>" leaves it out
141
+ when :before then Span.new(nil, s.b, s.grain, nil, nil, s.ub)
142
+ # D44: "on or before X", "X or earlier": up to X's end, or to a clock time itself
143
+ when :through then clock?(s) ? Span.new(nil, s.b, s.grain, nil, nil, s.ub) : Span.new(nil, s.e, s.grain, nil, nil, s.ue)
144
+ when :from then Span.new(s.b, nil, s.grain, nil, s.ub)
145
+ else clock?(s) ? Span.new(s.b, nil, s.grain, nil, s.ub) : Span.new(s.e, nil, s.grain, nil, s.ue)
146
+ end
147
+ end.reject { |s| s.b && s.e && s.e < s.b } # D44: "until 2014" said in 2026 runs backwards, so it is no time
148
+ end
149
+
150
+ # D44: "3 days starting next monday", "the week beginning february 4": that many units from its start
151
+ def self.lasting(node, ctx)
152
+ n, u = Period.whole(node.unit.count, node.unit.unit)
153
+ Resolve.spans(node.start, ctx).map { |s| Span.new(s.b, Period.add(s.b, n, u, ctx), s.grain, nil) }
154
+ end
155
+
156
+ # D40: "after christmas" said on Dec 27 is this year's, whose after still holds now. Only holidays: "after
157
+ # january" said in October is next year's (D34)
158
+ def self.current(x, ctx)
159
+ cyc = Periodic.cycle(x)
160
+ list = Periodic.at(x, ctx, Period.index(cyc, ctx, ctx.now_local), cyc)
161
+ list if list.any? && list[0].e <= ctx.now_local
162
+ end
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,187 @@
1
+ module Acute
2
+ module Resolve
3
+ # A Recur to a Rule. DTSTART walks the days from now (R2) rather than a one-off cycle: BYSETPOS, -2FR and
4
+ # lists of days have no one-off node to walk with.
5
+ module Recur
6
+ DAY = 86_400
7
+ FREQ = { second: [:secondly, 1], minute: [:minutely, 1], hour: [:hourly, 1], day: [:daily, 1], week: [:weekly, 1],
8
+ fortnight: [:weekly, 2], month: [:monthly, 1], quarter: [:monthly, 3], year: [:yearly, 1] }.freeze
9
+ FINE = { secondly: true, minutely: true, hourly: true }.freeze
10
+ STEP = { secondly: 1, minutely: 60, hourly: 3600 }.freeze
11
+ FINE_GRAIN = { second: true, minute: true, hour: true }.freeze
12
+ SEARCH = 8 * 366 + 1 # R9: the next 29 February can be 8 years away
13
+ EVERY_DAY = [0, 1, 2, 3, 4, 5, 6].freeze
14
+ MAX_EXDATES = 24
15
+
16
+ # A Rule, or a Failure's code
17
+ # text: the input, which the Rule carries and nothing here reads
18
+ def self.call(r, ctx, text)
19
+ freq, mult = FREQ[r.unit] if r.unit
20
+ return :conflict if r.unit && !freq
21
+ interval = (r.interval || 1) * (mult || 1)
22
+ return :invalid_date if interval == 0 # R15
23
+ if r.window # R27: the steps inside the window are the rule's clocks, on each day it runs
24
+ return :conflict unless FINE[freq] && r.clocks.empty? && !r.part
25
+ steps = window(r.window, interval * STEP[freq], ctx)
26
+ return steps if Symbol === steps
27
+ freq = nil
28
+ interval = 1
29
+ end
30
+ days = r.days.uniq
31
+ days = EVERY_DAY if days.empty? && r.except_days.any? && (freq.nil? || freq == :daily)
32
+ days -= r.except_days # R12
33
+ return :invalid_date if days.empty? && r.days.any? || r.except_days.any? && days.empty?
34
+ month = r.month
35
+ monthdays = r.monthdays.uniq
36
+ if r.date
37
+ d = r.date.sep ? Dates.orders(r.date, ctx)[0] : r.date
38
+ return :invalid_date unless d
39
+ month = d.month
40
+ monthdays = [d.day]
41
+ end
42
+ if month
43
+ return :conflict if monthdays.empty?
44
+ freq ||= :yearly # R9
45
+ return :conflict unless freq == :yearly
46
+ elsif r.nths.any? || r.setpos || monthdays.any?
47
+ freq ||= :monthly # R8, R9
48
+ return :conflict unless freq == :monthly
49
+ return :invalid_date if r.nths.any? { |n, _| n == 0 || n.abs > 5 }
50
+ days = MON_FRI if r.setpos
51
+ elsif days.any?
52
+ freq = :weekly if freq.nil? || freq == :daily # R6
53
+ return :conflict unless freq == :weekly
54
+ elsif r.clocks.any? || r.part || steps
55
+ freq ||= :daily
56
+ end
57
+ return :conflict unless freq
58
+ return :conflict if r.part && r.clocks.empty? # R15: "every morning"
59
+ return :conflict if [r.until, r.within, r.length].compact.size > 1 || (r.count && (r.until || r.within || r.length))
60
+
61
+ secs = steps || r.clocks.map { clock(_1, r.part, ctx) }.uniq.sort
62
+ return :conflict unless grid?(secs) # R4
63
+ timed = !secs.empty? || FINE[freq]
64
+
65
+ from = ctx.now
66
+ if r.start || r.within # R13
67
+ x = first(r.start || r.within, ctx)
68
+ return x if Symbol === x
69
+ from = x.begin if x.begin && (r.start || x.begin > from)
70
+ end
71
+ rule = Rule::Parts.new(freq, interval, month, monthdays, r.nths.any? ? [] : days, r.nths, r.setpos, secs)
72
+ start = dtstart(rule, from, ctx)
73
+ return :invalid_date unless start
74
+
75
+ last = r.until || r.within
76
+ if last
77
+ x = first(last, ctx)
78
+ return x if Symbol === x
79
+ until_ = ending(x, timed, ctx)
80
+ return until_ if Symbol === until_
81
+ return :no_occurrence if (timed ? ctx.local(until_) : until_) < start # R9
82
+ elsif r.length
83
+ until_ = length(r.length, start, timed, ctx)
84
+ end
85
+ bare = nil
86
+ exdates = r.except.map do |node|
87
+ x = first(node, ctx)
88
+ return x if Symbol === x
89
+ next ctx.local(x.begin) if FINE[freq] && FINE_GRAIN[x.grain] # R12: that instant, an occurrence or not
90
+ day = ctx.local(x.begin).div(DAY)
91
+ next day unless timed
92
+ next secs.map { day * DAY + _1 } unless FINE[freq]
93
+ bare ||= Rule.new(rule, ctx.zone.name, start, true, nil, nil, [], false, ctx.dst_policy, now: ctx.now, text: text)
94
+ walls = day_walls(bare, day, ctx)
95
+ return :conflict if walls.size > MAX_EXDATES # R12: "every 15 minutes except october 18"
96
+ walls
97
+ end
98
+ Rule.new(rule, ctx.zone.name, start, timed, r.count, until_, exdates.flatten, r.start || r.within ? true : false,
99
+ ctx.dst_policy, now: ctx.now, text: text)
100
+ end
101
+
102
+ # R4: seconds of the day, as D4, D30 and D35 read the clock, first reading only
103
+ def self.clock(c, part, ctx)
104
+ return Clock.candidates(c, ctx, false)[0] unless part
105
+ day = ctx.today
106
+ Periodic.clock(c, G::Part.new(part), Named.part(part, day, ctx), ctx)[0].b - day * DAY
107
+ end
108
+
109
+ # R4: each hour with each minute, all at one second, as BYHOUR × BYMINUTE gives them
110
+ def self.grid?(secs)
111
+ rest = secs.map { _1 % 3600 }.uniq
112
+ rest.map { _1 % 60 }.uniq.size <= 1 && secs.size == secs.map { _1 / 3600 }.uniq.size * rest.size
113
+ end
114
+
115
+ # R27: the seconds of the day from the window's start, a step apart, before its end; within one day
116
+ def self.window(node, step, ctx)
117
+ x = first(node, ctx)
118
+ return x if Symbol === x
119
+ return :incomplete unless x.begin && x.end
120
+ b = ctx.local(x.begin)
121
+ day = b.div(DAY)
122
+ e = ctx.local(x.end) - day * DAY
123
+ b -= day * DAY
124
+ return :conflict if e > DAY || e <= b
125
+ (b...e).step(step).to_a
126
+ end
127
+
128
+ def self.first(node, ctx)
129
+ readings = Resolve.call(node, ctx, failure: true)
130
+ return readings.code if Failure === readings
131
+ readings[0] || :invalid_date
132
+ end
133
+
134
+ # R2, R3, R14: the first occurrence on or after from, as local wall seconds (whole days: a day number)
135
+ def self.dtstart(rule, from, ctx)
136
+ local = ctx.local(from)
137
+ first = local.div(DAY)
138
+ day = first
139
+ while day < first + SEARCH
140
+ if rule.on?(day)
141
+ return day unless rule.secs.any? || FINE[rule.freq]
142
+ return day == first ? local : day * DAY if rule.secs.empty?
143
+ rule.secs.each { |s| return day * DAY + s if ctx.zone.local_to_utc(day * DAY + s, ctx) >= from }
144
+ end
145
+ day += 1
146
+ end
147
+ end
148
+
149
+ # R12: the wall times of an hourly or finer rule's occurrences on a local day, however many hours DST gives
150
+ # it; past MAX_EXDATES, more than that. The rule's end is left out, as a daily rule's times are.
151
+ def self.day_walls(rule, day, ctx)
152
+ from = ctx.zone.local_to_utc(day * DAY, ctx) - 1
153
+ walls = rule.next(MAX_EXDATES + 2, after: Time.at(from)).filter_map do |t|
154
+ wall = t.to_i + t.utc_offset
155
+ wall if wall < (day + 1) * DAY
156
+ end
157
+ walls.uniq
158
+ end
159
+
160
+ # R10, R14: through the end of x, inclusive; a clock is that instant
161
+ def self.ending(x, timed, ctx)
162
+ if FINE_GRAIN[x.grain]
163
+ return timed ? x.begin : ctx.local(x.begin).div(DAY)
164
+ end
165
+ return :incomplete unless x.end
166
+ day = ctx.local(x.end - 1).div(DAY)
167
+ timed ? ctx.zone.local_to_utc((day + 1) * DAY - 1, ctx) : day
168
+ end
169
+
170
+ # R11: from DTSTART, its end left out; days or longer end on the whole day before
171
+ def self.length(units, start, timed, ctx)
172
+ t = timed ? start : start * DAY
173
+ if units.all? { Period::SECONDS[_1.unit] }
174
+ return ctx.zone.local_to_utc(t, ctx) + units.sum { Period::SECONDS[_1.unit] * _1.count } - 1
175
+ end
176
+ units.each do |u|
177
+ n, unit = Period.whole(u.count, u.unit)
178
+ t = Period.add(t, n, unit, ctx)
179
+ end
180
+ day = t.div(DAY) - 1
181
+ timed ? ctx.zone.local_to_utc((day + 1) * DAY - 1, ctx) : day
182
+ end
183
+
184
+ MON_FRI = G::MON_FRI
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,146 @@
1
+ module Acute
2
+ module Resolve
3
+ # next / this / last / past, with a unit ("next week", "past 3 days") or a repeating phrase ("next friday")
4
+ module Rel
5
+ DAY = Period::DAY
6
+ NEXT_TWO = Assumption.for(:next_two_readings)
7
+ THIS_PASSED = Assumption.for(:this_passed)
8
+ ROLLING = Assumption.for(:rolling_window)
9
+ CENTURY = Assumption.for(:century_from_00)
10
+ TONIGHT = G::Part.new(:tonight).freeze
11
+
12
+ def self.spans(node, ctx)
13
+ x = node.node
14
+ word = node.word
15
+ return Period.fiscal(x, ctx, word == :this ? 0 : word == :next || word == :coming ? 1 : -1) if G::Fiscal === x
16
+ return Dates.orders(x, ctx).flat_map { |d| periodic(word, d, ctx) } if G::Date === x && x.sep # D48: "last 9/11"
17
+ return periodic(word, x, ctx) unless G::Unit === x || G::Clock === x
18
+ return unit(word, x.unit, ctx) if G::Unit === x && !x.count
19
+ word = :next if word == :coming
20
+ return clock(word, x, ctx) if G::Clock === x
21
+ counted(word, x.count, x.unit, ctx)
22
+ end
23
+
24
+ # D20, D21, D32, D40
25
+ def self.periodic(word, x, ctx)
26
+ cyc = Periodic.cycle(x)
27
+ return [] unless cyc
28
+ now = ctx.now_local
29
+ i = Period.index(cyc, ctx, now)
30
+ case word
31
+ when :coming then Periodic.first(x, ctx, i, cyc) { |s| s.b > now } # D40: the first one after now, alone
32
+ when :next
33
+ return Periodic.at(x, ctx, i + 1, cyc) if cyc == :day # D40: "next night" is tomorrow night
34
+ coming = Periodic.first(x, ctx, i, cyc) { |s| s.b > now }
35
+ # D40: said while one is under way, it means the one after, one answer
36
+ return coming if under_way?(x, ctx, i, cyc, now)
37
+ following = Periodic.at(x, ctx, i + 1, cyc)
38
+ out = merge(coming, following)
39
+ ctx.note(NEXT_TWO) if out.size > 1 && following.any? && coming.any? && following[0].b != coming[0].b
40
+ out
41
+ when :this
42
+ cur = Periodic.at(x, ctx, i, cyc)
43
+ return cur if cyc == :day # D1: "this morning" is today's, even once it has passed
44
+ prev = Periodic.at(x, ctx, i - 1, cyc)
45
+ return prev if prev.any? && prev[0].b <= now && prev[0].e > now # D8: the weekend under way
46
+ return cur if cur.empty? || cur[0].e > now || ctx.past # D44: looking back, the one passed
47
+ ctx.note(THIS_PASSED)
48
+ merge(Periodic.first(x, ctx, i + 1, cyc) { |s| s.e > now }, cur)
49
+ else
50
+ x = TONIGHT if G::Part === x && x.name == :night # D1: last night starts at 17:00
51
+ # D51: said at exactly 00:00, "last night" is the calendar yesterday's, as D41 reads "tomorrow"
52
+ return Periodic.last(x, ctx, i, cyc) { |s| s.b < now } if now % DAY == 0 && x.equal?(TONIGHT)
53
+ Periodic.last(x, ctx, i, cyc) { |s| s.e <= now }
54
+ end
55
+ end
56
+
57
+ def self.under_way?(x, ctx, i, cyc, now)
58
+ [i - 1, i].any? do |j|
59
+ s = Periodic.at(x, ctx, j, cyc)[0]
60
+ s && s.b <= now && s.e > now
61
+ end
62
+ end
63
+
64
+ # The main readings first, then the alternatives of each
65
+ def self.merge(a, b)
66
+ out = []
67
+ [a.first(1), b.first(1), a.drop(1), b.drop(1)].each do |list|
68
+ list.each { |s| out << s unless out.any? { |o| o.b == s.b && o.e == s.e } }
69
+ end
70
+ out
71
+ end
72
+
73
+ # "next week", "this month", "last year"; D38 for hours and minutes; D31 for "past week"
74
+ def self.unit(word, u, ctx)
75
+ now = ctx.now_local
76
+ return counted(word == :this || word == :coming ? :next : word, 2, :week, ctx) if u == :fortnight # D40
77
+ return counted(:past, 1, u, ctx) if word == :past
78
+ k = word == :next || word == :coming ? 1 : word == :this ? 0 : -1
79
+ if u == :business_day
80
+ # D44: "this weekday" on a weekend is Monday
81
+ b = k == 0 ? Period.business(Period.day(now) * DAY - DAY, 1, ctx) : Period.business(Period.day(now) * DAY, k, ctx)
82
+ return [Span.new(b, b + DAY, :day, nil)]
83
+ end
84
+ cur = Period.unit(u, ctx, now)
85
+ b = Period.add(cur.b, k, u)
86
+ calendar = Span.new(b, Period.add(b, 1, u), cur.grain, nil)
87
+ if u == :century # D12, D44
88
+ ctx.note(CENTURY)
89
+ return [calendar, Span.new(Period.add(b, 1, :year), Period.add(calendar.e, 1, :year), cur.grain, nil)]
90
+ end
91
+ if u == :decade && k != 0 && word != :coming # D40: the ten years from now first; "this coming" is the next to start
92
+ rolling = k > 0 ? Span.new(now, Period.add(now, 1, u), u, nil) : Span.new(Period.add(now, -1, u), now, u, nil)
93
+ return [calendar] if rolling.b == calendar.b
94
+ ctx.note(ROLLING)
95
+ return [rolling, calendar]
96
+ end
97
+ # D42: not "next second", whose rolling reading would be this second
98
+ return [calendar] unless (u == :hour || u == :minute) && k != 0
99
+ ctx.note(ROLLING)
100
+ [k > 0 ? window(ctx, Period::SECONDS[u], u) : window(ctx, -Period::SECONDS[u], u), calendar]
101
+ end
102
+
103
+ # D7, D31: rolling from now first, then whole units: the next N, or the N ending with this one and the N
104
+ # before it
105
+ def self.counted(word, n, u, ctx)
106
+ n, u = Period.whole(n, u)
107
+ if u == :fortnight
108
+ n *= 2
109
+ u = :week
110
+ end
111
+ now = ctx.now_local
112
+ ctx.note(ROLLING)
113
+ if word == :next || word == :this
114
+ return [window(ctx, n * Period::SECONDS[u], u)] if Period::SECONDS[u]
115
+ out = [Span.new(now, Period.add(now, n, u, ctx), u, nil)]
116
+ b = Period.add(Period.unit(u, ctx, now).b, 1, u, ctx)
117
+ out << Span.new(b, Period.add(b, n, u, ctx), u, nil)
118
+ else
119
+ return [window(ctx, -n * Period::SECONDS[u], u)] if Period::SECONDS[u]
120
+ out = [Span.new(Period.add(now, -n, u, ctx), now, u, nil)]
121
+ cur = Period.unit(u, ctx, now).b
122
+ e = Period.add(cur, 1, u, ctx)
123
+ out << Span.new(Period.add(e, -n, u, ctx), e, u, nil) << Span.new(Period.add(cur, -n, u, ctx), cur, u, nil)
124
+ end
125
+ end
126
+
127
+ # From now to secs away, either way. D23: hours and smaller are absolute time, so "the last 48 hours" across
128
+ # a DST change is still 48 hours
129
+ def self.window(ctx, secs, unit)
130
+ utc = ctx.now + secs
131
+ t = ctx.local(utc)
132
+ secs > 0 ? Span.new(ctx.now_local, t, unit, nil, ctx.now, utc) : Span.new(t, ctx.now_local, unit, nil, utc, ctx.now)
133
+ end
134
+
135
+ # D18: "next 3pm" is the nearest one after now, "last 3pm" the nearest before
136
+ def self.clock(word, c, ctx)
137
+ return Clock.bare(c, ctx) if word == :next || word == :this
138
+ past = ctx.past
139
+ ctx.past = :back
140
+ out = Clock.bare(c, ctx)
141
+ ctx.past = past
142
+ out
143
+ end
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,81 @@
1
+ module Acute
2
+ module Resolve
3
+ # "in 3 days", "2 hours ago", "a week from tomorrow", "3 business days after friday"
4
+ module Shift
5
+ FINE = { second: 0, minute: 1, hour: 2 }.freeze
6
+
7
+ # D23: hours and smaller are absolute time; days and larger are wall-clock arithmetic that keeps the
8
+ # time of day. With no anchor the result is an instant.
9
+ def self.spans(node, ctx)
10
+ n, u = Period.whole(node.count, node.unit)
11
+ unless node.anchor
12
+ if (len = Period::SECONDS[u])
13
+ utc = ctx.now + n * len
14
+ t = ctx.local(utc)
15
+ return [Span.new(t, t + 1, :second, nil, utc, utc + 1)]
16
+ end
17
+ t = Period.add(ctx.now_local, n, u, ctx)
18
+ return [Span.new(t, t + 1, :second, nil)]
19
+ end
20
+ len = Period::SECONDS[u]
21
+ Resolve.spans(node.anchor, ctx).map do |s|
22
+ next calendar(s, n, u, ctx) unless len
23
+ # D44: from the anchor's instant, at a grain no coarser than the unit
24
+ ub = (s.ub || ctx.zone.local_to_utc(s.b, ctx, ctx.dst_policy)) + n * len
25
+ grain = s.grain
26
+ size = s.ue ? s.ue - s.ub : s.e - s.b
27
+ if !(r = FINE[grain]) || r > FINE[u]
28
+ grain = u
29
+ size = len
30
+ end
31
+ Span.new(ctx.local(ub), ctx.local(ub + size), grain, nil, ub, ub + size)
32
+ end
33
+ end
34
+
35
+ RANK = { second: 0, minute: 1, hour: 2, day: 3, business_day: 3, weekend: 4, week: 4, fortnight: 4, month: 5,
36
+ quarter: 6, season: 6, year: 7, decade: 8, century: 9 }.freeze
37
+ GRAIN = { business_day: :day, fortnight: :week }.freeze
38
+
39
+ # D44: a shift in days or longer from a longer anchor counts from its start at the unit's grain ("1 day
40
+ # before next month"); a day or less keeps its length ("a month from today" on 28 February is 28 March)
41
+ def self.calendar(s, n, u, ctx)
42
+ b = Period.add(s.b, n, u, ctx)
43
+ rank = RANK.fetch(s.grain)
44
+ return Span.new(b, Period.add(b, 1, u, ctx), GRAIN[u] || u, nil) if rank > RANK.fetch(u)
45
+ return Span.new(b, b + s.e - s.b, s.grain, s.point && s.point - s.b + b) if rank <= 3
46
+ Span.new(b, Period.add(s.e, n, u, ctx), s.grain, s.point && Period.add(s.point, n, u, ctx))
47
+ end
48
+
49
+ # D44: "3 fridays from now" is the third Friday after now, not counting today's; "2 weekends ago" the
50
+ # second whose end has passed. One a cycle, so the nth is n - 1 cycles on from the first. From an anchor
51
+ # with alternatives, each gives one ("friday after next").
52
+ def self.counted(node, ctx)
53
+ n = node.n
54
+ return nth_from(node.node, n, ctx.now_local + (n > 0 ? 1 : 0), ctx) unless node.anchor
55
+ a = Resolve.spans(node.anchor, ctx)
56
+ return nth_from(node.node, n, n > 0 ? a[0].e : a[0].b, ctx) if a.size <= 1 && !a.empty?
57
+ a.flat_map { |s| nth_from(node.node, n, n > 0 ? s.e : s.b, ctx).first(1) }.uniq
58
+ end
59
+
60
+ # D48: one a cycle, so the n nearest lie within n cycles of the anchor's; nearest by start, the earlier
61
+ # on a tie
62
+ def self.closest(node, ctx)
63
+ cyc = Periodic.cycle(node.node)
64
+ a = Resolve.spans(node.anchor, ctx)
65
+ return [] if !cyc || a.empty?
66
+ t = a[0].b
67
+ i = Period.index(cyc, ctx, t)
68
+ all = ((i - node.n)..(i + node.n)).flat_map { |j| Periodic.at(node.node, ctx, j, cyc) }
69
+ all.sort_by { |s| [(s.b - t).abs, s.b] }[node.n - 1, 1] || []
70
+ end
71
+
72
+ def self.nth_from(x, n, t, ctx)
73
+ cyc = Periodic.cycle(x)
74
+ i = Period.index(cyc, ctx, t)
75
+ first = n > 0 ? Periodic.first(x, ctx, i, cyc) { |s| s.b >= t } : Periodic.last(x, ctx, i, cyc) { |s| s.e <= t }
76
+ return first if first.empty? || n.abs == 1
77
+ Periodic.at(x, ctx, Period.index(cyc, ctx, first[0].b) + n - (n <=> 0), cyc)
78
+ end
79
+ end
80
+ end
81
+ end