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,307 @@
1
+ module Acute
2
+ # Atoms led by a word: weekdays, months, parts of day, next/last, early/late, "in", "a week"
3
+ class Grammar
4
+ private
5
+
6
+ # An if chain, the commonest kinds first: a case over constants tests each with a method call
7
+ def atom(loose)
8
+ t = @t
9
+ i = @i
10
+ k = t[i]
11
+ v = t[i + 1]
12
+ if k == L::NUM then number_atom(loose)
13
+ elsif k == L::CLOCK then written(v, t[i + 2])
14
+ elsif k == L::DAY
15
+ @i += S
16
+ # "tomorrow week": a week after it
17
+ if t[i + S] == L::UNIT && WEEKS[u = t[i + S + 1]] && (k = t[i + 2 * S]) != L::NUM && k != L::ORD
18
+ @i += S
19
+ return Shift.new(1, u, DayRef.new(v))
20
+ end
21
+ DayRef.new(v)
22
+ elsif k == L::WDAY
23
+ return failure(:incomplete) if t[i + 2] & L::PLURAL != 0
24
+ @i += S
25
+ around(of(Weekday.new(v)))
26
+ elsif k == L::MONTH then around(month_atom)
27
+ elsif k == L::REL then rel_atom
28
+ elsif k == L::NAMED
29
+ @i += S
30
+ named(v)
31
+ elsif k == L::ORD then ordinal_atom
32
+ elsif k == L::PART
33
+ return failure(:incomplete) if t[i + 2] & L::PLURAL != 0
34
+ @i += S
35
+ of(Part.new(v))
36
+ elsif k == L::NOW
37
+ @i += S
38
+ NOW
39
+ elsif k == L::HOLIDAY
40
+ return failure(:incomplete) if t[i + 2] & L::PLURAL != 0
41
+ @i += S
42
+ # D48: "labor day weekend" is the long weekend with the holiday
43
+ if t[@i] == L::WEEKEND
44
+ @i += S
45
+ return Within.new(WEEKEND, Holiday.new(v))
46
+ end
47
+ around(of(Holiday.new(v)))
48
+ elsif k == L::WORD then word_atom(v)
49
+ elsif k == L::ART then article_atom
50
+ elsif k == L::FUZZ then fuzzy_atom
51
+ elsif k == L::WEEKEND
52
+ @i += S
53
+ around(WEEKEND)
54
+ elsif k == L::SEASON
55
+ @i += S
56
+ of(Season.new(v))
57
+ elsif k == L::UNIT
58
+ return clock_words(15) if v == :quarter && @t[i + S] == L::REL || @t[i + S + 1] == :to
59
+ return iso_week if v == :week && week_number?(i + S)
60
+ # D44: "H1 2027", "H2 2026"
61
+ if v == :hour && t[i + 4] - t[i + 3] == 1 && t[i + S] == L::NUM && t[i + S + 1].between?(1, 2) && t[i + 4] == t[i + S + 3] &&
62
+ t[i + 2 * S] == L::NUM && t[i + 2 * S + 2] & L::DIGITS == 4
63
+ @i += 3 * S
64
+ return Within.new(half(t[i + S + 1]), Date.new(t[i + 2 * S + 1], nil, nil, nil, false))
65
+ end
66
+ # "the year 1980", "in the year '89", "the year twenty ten", "the year two thousand"
67
+ if v == :year && t[i + S] == L::NUM && ((f = t[i + S + 2]) & L::DIGITS == 4 || f & L::SHORT != 0 ||
68
+ (f & L::WORDY != 0 && (t[i + 2 * S] == L::NUM || t[i + 2 * S + 1] == :thousand)))
69
+ @i += S
70
+ return number_atom(true)
71
+ end
72
+ # D48: "year 247": three digits after "year" are the year as written
73
+ if v == :year && t[i + S] == L::NUM && t[i + S + 2] & L::DIGITS == 3 && t[i + S + 2] & L::ZERO == 0 && i + 2 * S >= @n
74
+ @i += 2 * S
75
+ return Date.new(t[i + S + 1], nil, nil, nil, false)
76
+ end
77
+ if (w = t[i + S + 1]) == :after || w == :before # "the day after tomorrow"
78
+ @i += S
79
+ # D44: "the week after next", "the month after the next", "week before last": the one after next
80
+ # week or before last week, when "next" or "last" has nothing of its own
81
+ j = i + 2 * S
82
+ j += S if t[j] == L::WORD && t[j + 1] == :the
83
+ if t[j] == L::REL && (r = t[j + 1]) == (w == :after ? :next : :last) && nothing_own?(j + S)
84
+ @i = j + S
85
+ return Shift.new(w == :after ? 1 : -1, v, Rel.new(r, Unit.new(v, nil)))
86
+ end
87
+ return shift_tail(Unit.new(v, 1))
88
+ end
89
+ return shift_tail(Unit.new(v, 1)) if starting?(i + S) && (@i += S) # "the week starting february 4"
90
+ # D44: "the week of october 6th", "the month of may": the one that holds it. D51: "the year of 2008"
91
+ if (v == :week || v == :month || v == :year) && w == :of && t[i + S] == L::WORD
92
+ @i += 2 * S
93
+ node = item(true)
94
+ return Failure === node ? node : Holding.new(v, node)
95
+ end
96
+ failure(:incomplete)
97
+ elsif k == L::DECADE then decade_atom
98
+ elsif k == L::MER && t[i + S] == L::CLOCK && i >= S && t[i - S] == L::NUM then meridiem_first(v)
99
+ else failure(:conflict)
100
+ end
101
+ end
102
+
103
+ # D48: "2020/10/9 PM 2:26": a meridiem before the clock after a date, the East Asian order
104
+ def meridiem_first(v)
105
+ at = @i
106
+ @i += S
107
+ c = written(@t[@i + 1], @t[@i + 2])
108
+ return failure(:conflict, at) unless Clock === c && !c.meridiem && c.hour >= 1 && c.hour <= 12
109
+ Clock.new(c.hour, c.min, c.sec, v == 0 ? :am : :pm, nil, false, c.seconds, c.frac)
110
+ end
111
+
112
+ # "the 90s", "1990s"; "30s ago" is seconds
113
+ def decade_atom
114
+ return shift_tail(quantity) if SHIFT_TAIL[@t[@i + S + 1]] # D47: "5400s ago" as "30s ago"
115
+ v = @t[@i + 1]
116
+ @i += S
117
+ Years.new(:decade, v, v < 100)
118
+ end
119
+
120
+ def word_atom(v)
121
+ i = @i
122
+ case v
123
+ when :in
124
+ @i += S
125
+ if @t[@i + 1] == :around # D14: "in about 5 hours"
126
+ @approx = true
127
+ @i += S
128
+ end
129
+ if (q = duration_quantity)
130
+ return q if Failure === q
131
+ return shifts(1, q, more_units(q), nil)
132
+ end
133
+ @i += S if @t[@i + 1] == :the
134
+ return failure(:incomplete) if @i >= @n
135
+ # D42: "in 3" is a count without its unit, not 3 o'clock
136
+ return failure(:incomplete) if @t[@i] == L::NUM && @t[@i + 2] & L::DIGITS <= 2 && @i + S >= @n
137
+ atom(false)
138
+ when :within
139
+ @i += S
140
+ if @t[@i + 1] == :around
141
+ @approx = true
142
+ @i += S
143
+ end
144
+ # "within the next 5 days" is within 5 days
145
+ @i += 2 * S if @t[@i + 1] == :the && @t[@i + S] == L::REL && @t[@i + S + 1] == :next
146
+ @i += S if @t[@i] == L::REL && @t[@i + 1] == :next && @t[@i + S] == L::NUM # "within next 10 months"
147
+ q = quantity
148
+ q && !(Failure === q) ? Rel.new(:next, q) : failure(:incomplete, i) # D31
149
+ when :q
150
+ return failure(:incomplete) unless @t[i + S] == L::NUM && (1..4).cover?(@t[i + S + 1])
151
+ @i += 2 * S
152
+ Nth.new(@t[i + S + 1], Unit.new(:quarter, nil))
153
+ when :fy
154
+ return failure(:incomplete) unless @t[i + S] == L::NUM
155
+ @i += 2 * S
156
+ y = @t[i + S + 1]
157
+ Fiscal.new(@t[i + S + 2] & L::DIGITS == 2 ? 2000 + y : y)
158
+ when :half
159
+ (q = quantity) ? shift_tail(q) : clock_words(30)
160
+ when :sametime then same_time
161
+ when :closest then closest(1)
162
+ when :all # D48: "all week" is this whole week
163
+ return failure(:conflict) unless @t[i + S] == L::UNIT && @t[i + S + 2] & L::PLURAL == 0
164
+ @i += S
165
+ this_unit
166
+ when :rest # D48: "rest of the week" is from now to its end, as "through this week" (D16)
167
+ return failure(:conflict) unless @t[i + S] == L::WORD && @t[i + S + 1] == :of
168
+ @i += 2 * S
169
+ @i += S if @t[@i + 1] == :the
170
+ return failure(:incomplete) if @i >= @n
171
+ node = @t[@i] == L::UNIT ? this_unit : atom(true)
172
+ Failure === node ? node : Open.new(:until, node)
173
+ when :hundred, :thousand, :million then (q = quantity) ? shift_tail(q) : failure(:conflict)
174
+ when :couple
175
+ (q = quantity) ? shift_tail(q) : failure(@i + S < @n ? :conflict : :incomplete, @i + S)
176
+ else failure(:conflict)
177
+ end
178
+ end
179
+
180
+ # D48: "the closest monday to oct 5th", "the 3rd closest xmas to today"
181
+ def closest(n)
182
+ @i += S
183
+ node = atom(true)
184
+ return node if Failure === node
185
+ return failure(@i < @n ? :conflict : :incomplete) unless @t[@i + 1] == :to && @t[@i] == L::WORD
186
+ @i += S
187
+ anchor = atom(true)
188
+ Failure === anchor ? anchor : Closest.new(n, node, anchor)
189
+ end
190
+
191
+ def article_atom
192
+ i = @i
193
+ if @t[i + S] == L::UNIT && ((u = @t[i + S + 1]) == :quarter || u == :minute) && clock_word?(i + 2 * S) && hour?(i + 3 * S)
194
+ return clock_words(u == :quarter ? 15 : 1)
195
+ end
196
+ q = quantity
197
+ return failure(:conflict) unless q
198
+ shift_tail(q)
199
+ end
200
+
201
+ def rel_atom
202
+ w = @t[@i + 1]
203
+ start = @i
204
+ @i += S
205
+ if w == :this && @t[@i] == L::REL && @t[@i + 1] != :this # D40: "this coming week"
206
+ w = @t[@i + 1] == :next ? :coming : :past # D44: "this past sunday" is "past sunday"; D48: so is "this last"
207
+ @i += S
208
+ end
209
+ k = @t[@i]
210
+ v = @t[@i + 1]
211
+ # D42: "this second", "last second": with nothing after it to count, "second" is the unit
212
+ if k == L::ORD && unit_at(@i) && ![L::WDAY, L::UNIT, L::WEEKEND].include?(@t[@i + S])
213
+ k = L::UNIT
214
+ v = :second
215
+ end
216
+ return failure(:conflict) if (k == L::WDAY || k == L::PART) && @t[@i + 2] & L::PLURAL != 0
217
+ if k == L::WDAY || k == L::UNIT || k == L::WEEKEND
218
+ node = k == L::WDAY ? Weekday.new(v) : k == L::UNIT ? Unit.new(v, nil) : WEEKEND
219
+ @i += S
220
+ # "last friday of june"; "last sunday in march" when a month follows
221
+ return of(Nth.new(-1, node)) if w == :last && (@t[@i + 1] == :of || (@t[@i + 1] == :in && @t[@i + S] == L::MONTH))
222
+ # D48: "last tuesday this month" is the last of that month
223
+ if w == :last && k == L::WDAY && @t[@i] == L::REL && @t[@i + S + 1] == :month && @t[@i + S] == L::UNIT
224
+ month = atom(true)
225
+ return Within.new(Nth.new(-1, node), month)
226
+ end
227
+ Rel.new(w, node)
228
+ elsif k == L::NUM && unit_at(@i + S) && w != :this # D50: "this" takes no count: "this 5 years ago" is "5 years ago"
229
+ q = quantity
230
+ Rel.new(w, q)
231
+ elsif k == L::MONTH && @t[@i + S] != L::NUM && @t[@i + S] != L::ORD
232
+ @i += S
233
+ Rel.new(w, Date.new(nil, v, nil, nil, false))
234
+ elsif k == L::NUM && @t[@i + S] == L::SLASH && @t[@i + 3 * S] != L::SLASH && @t[@i + 2 * S] == L::NUM
235
+ d = atom(true) # D48: "last 9/11": the latest one, as "last christmas"
236
+ Failure === d || !(Date === d) ? failure(:conflict, start) : Rel.new(w, d)
237
+ elsif k == L::WORD && v == :work_week
238
+ @i += S # D48: "next working week" is Monday to Friday of next week
239
+ Within.new(Range.new(Weekday.new(1), Weekday.new(5)), Rel.new(w, Unit.new(:week, nil)))
240
+ elsif k == L::SEASON || k == L::HOLIDAY || k == L::PART
241
+ @i += S
242
+ Rel.new(w, k == L::SEASON ? Season.new(v) : k == L::HOLIDAY ? Holiday.new(v) : Part.new(v))
243
+ elsif k == L::WORD && v == :fy && @t[@i + S] != L::NUM # D17
244
+ @i += S
245
+ Rel.new(w, Fiscal.new(nil))
246
+ elsif (k == L::WORD && v == :q) || (k == L::ORD && @t[@i + S + 1] == :quarter) # D32: "next q1", "last 2nd quarter"
247
+ q = atom(false)
248
+ Failure === q ? q : Rel.new(w, q)
249
+ elsif (k == L::NUM && (@t[@i + S] == L::MER || @t[@i + S] == L::NAMED)) || k == L::CLOCK || k == L::NAMED
250
+ c = k == L::NAMED ? (@i += S) && named(v) : k == L::CLOCK ? written(v, @t[@i + 2]) : number(v, @t[@i + 2], true)
251
+ Failure === c ? c : Rel.new(w, c) # D18: "next 3pm"
252
+ else
253
+ failure(@i < @n ? :conflict : :incomplete, @i < @n ? @i : start)
254
+ end
255
+ end
256
+
257
+ EDGES = { eod: %i[end day], eom: %i[end month], eoy: %i[end year], bom: %i[start month], boy: %i[start year] }.freeze
258
+
259
+ def day_named?(i)
260
+ k = @t[i]
261
+ k == L::DAY || k == L::WDAY || k == L::MONTH
262
+ end
263
+
264
+ # "early next week", "mid-october", "end of the month", "late tonight"
265
+ def fuzzy_atom
266
+ pos = @t[@i + 1]
267
+ @i += S
268
+ # "mid day", "mid-night": midday and midnight
269
+ if pos == :mid && ((day = @t[@i + 1] == :day) || @t[@i + 1] == :night) && (@t[@i] == L::UNIT || @t[@i] == L::PART)
270
+ @i += S
271
+ return named(day ? 12 : 0)
272
+ end
273
+ # D2: "EOD", "end of day tomorrow", "EOD friday": the end of the day named next, else of today; "EOM",
274
+ # "BOY": of this month or year
275
+ if (edge = EDGES[pos])
276
+ pos, unit = edge
277
+ elsif @t[@i + 1] == :of && @t[@i + S] == L::UNIT && @t[@i + S + 1] == :day && day_named?(@i + 2 * S)
278
+ @i += 2 * S
279
+ unit = :day
280
+ end
281
+ if unit
282
+ node = unit == :day && day_named?(@i) ? atom(true) : Rel.new(:this, Unit.new(unit, nil))
283
+ return Failure === node ? node : Fuzzy.new(pos, node)
284
+ end
285
+ # "early tomorrow evening", "late friday night": the part of that day
286
+ if (@t[@i] == L::DAY || @t[@i] == L::WDAY) && @t[@i + S] == L::PART
287
+ day = atom(true)
288
+ return day if Failure === day
289
+ part = atom(true)
290
+ return Failure === part ? part : Within.new(Fuzzy.new(pos, part), day)
291
+ end
292
+ @i += S if @t[@i + 1] == :of
293
+ if @t[@i + 1] == :around && @t[@i] == L::WORD # D48: "beginning of around next week", as "around the beginning of"
294
+ @approx = true
295
+ @i += S
296
+ end
297
+ @i += S if @t[@i + 1] == :the
298
+ @i += S if @t[@i] == L::DASH && (@t[@i + S] == L::NUM || @t[@i + S] == L::DECADE) # "mid-1990s"
299
+ return failure(:incomplete) if @i >= @n
300
+ node = @t[@i] == L::UNIT ? this_unit : atom(true)
301
+ return node if Failure === node
302
+ # D44: "end of past week" is the end of last week; a rolling week's edges are now and a week ago
303
+ node = Rel.new(:last, node.node) if Rel === node && node.word == :past && Unit === node.node && !node.node.count
304
+ Fuzzy.new(pos, node)
305
+ end
306
+ end
307
+ end
@@ -0,0 +1,378 @@
1
+ require_relative "lexer"
2
+ require_relative "calendar"
3
+
4
+ module Acute
5
+ # Tokens to a tree of what was said, by recursive descent. It knows nothing of now, the zone or the
6
+ # D-rules: a bare "4" stays hour 4 with no am/pm, and "10/11" stays two numbers and a slash; resolve
7
+ # decides what they mean.
8
+ #
9
+ # phrase := (after|before|since|until|by|starting) body | around body | body
10
+ # body := between item and item | from item to item | item [(to|through|until|-) item]
11
+ # | atom+ (between item and | from item to) item
12
+ # item := atom+ [zone] each atom is one constituent: a date, a weekday, a clock, a shift...
13
+ #
14
+ # "around" anywhere in an item makes the whole phrase Approx (D14).
15
+ #
16
+ # An item's atoms are nested by size, the coarsest outside: "friday 3pm" is Within(Clock, Weekday) and
17
+ # "christmas 2015" is Within(Holiday, Date 2015). Two atoms of the same size conflict.
18
+ #
19
+ # Tokens are read straight from the flat Array: @t[i] is a kind and @t[i + 1] a value, both nil past the
20
+ # end. A function word's Symbol is no other kind's value, so `@t[@i + 1] == :of` is the word "of".
21
+ # @api private
22
+ class Grammar
23
+ # h24: written with a leading zero, a 24-hour form (D4); seconds: written with seconds, or :hour for an hour alone
24
+ Clock = Struct.new(:hour, :min, :sec, :meridiem, :named, :h24, :seconds, :frac)
25
+ DayRef = Struct.new(:offset)
26
+ # Any of the three may be nil. With sep (:slash, :dash, :dot, :space), month and day are the first and
27
+ # second numbers as written and resolve picks the order (D10, D37); short: a two-digit year (D23)
28
+ Date = Struct.new(:year, :month, :day, :sep, :short)
29
+ Weekday = Struct.new(:wday)
30
+ Part = Struct.new(:name)
31
+ Season = Struct.new(:name)
32
+ Holiday = Struct.new(:name)
33
+ Weekend = Struct.new(:count)
34
+ Fiscal = Struct.new(:year)
35
+ IsoWeek = Struct.new(:week, :day) # D13: ISO week 1-53, and its day 1-7 (Monday 1) or nil
36
+ # A decade or century from its first year; short: a decade's two digits, for the 50-year window (D12)
37
+ Years = Struct.new(:unit, :year, :short)
38
+ # A unit, alone ("next week") or counted ("next 3 days", "3 days" in "3 days ago")
39
+ Unit = Struct.new(:unit, :count)
40
+ Rel = Struct.new(:word, :node) # next / this / last / past / coming
41
+ Shift = Struct.new(:count, :unit, :anchor) # signed count of units from the anchor, or from now
42
+ Counted = Struct.new(:n, :node, :anchor) # the nth node after the anchor or now (n < 0: before)
43
+ Closest = Struct.new(:n, :node, :anchor) # the nth node nearest the anchor, either side
44
+ Nth = Struct.new(:n, :node) # the nth (n = -1: the last) node inside its outer period
45
+ Within = Struct.new(:inner, :outer)
46
+ Range = Struct.new(:from, :to)
47
+ Open = Struct.new(:dir, :node) # :after, :before, :since, :until, :upto, :from, :through
48
+ Lasting = Struct.new(:unit, :start) # a counted Unit from the start's start: "3 days starting monday"
49
+ Holding = Struct.new(:unit, :node) # the calendar unit that holds the node: "the week of the 18th"
50
+ Approx = Struct.new(:node)
51
+ Fuzzy = Struct.new(:pos, :node) # :early, :mid, :late, :start, :end
52
+ Zoned = Struct.new(:node, :offset, :numeric)
53
+ Now = Struct.new
54
+ NOW = Now.new.freeze
55
+ WEEKEND = Weekend.new(nil).freeze
56
+ # Each node's class as a Symbol, to dispatch on in one step: a case over the classes tests them in turn
57
+ KIND = { Clock => :clock, DayRef => :day_ref, Date => :date, Weekday => :weekday, Part => :part, Season => :season,
58
+ Holiday => :holiday, Weekend => :weekend, Fiscal => :fiscal, Unit => :unit, Rel => :rel, Shift => :shift,
59
+ Nth => :nth, Within => :within, Range => :range, Open => :open, Approx => :approx, Fuzzy => :fuzzy,
60
+ Zoned => :zoned, Now => :now, Years => :years, IsoWeek => :iso_week, Counted => :counted,
61
+ Lasting => :lasting, Holding => :holding, Closest => :closest }.freeze
62
+
63
+ L = Lexer
64
+ S = Lexer::STRIDE
65
+ OPEN = { after: :after, before: :before, since: :since, until: :until, by: :until, upto: :upto, through: :until,
66
+ onafter: :from, onbefore: :through }.freeze
67
+ RANGE = { to: true, through: true, until: true, upto: true, butbefore: true }.freeze # D48: "X up to Y"
68
+ BEFORE_DAY = { the: true, from: true, between: true }.freeze # words that make "ninth" a day
69
+ SHIFT_TAIL = { ago: true, hence: true, later: true, from: true, after: true, before: true }.freeze
70
+ # The kinds item looks at before taking an atom; any other token starts one
71
+ ITEM_SPECIAL = Array.new(L::DECADE + 1) { [L::AT, L::WORD, L::ZONE, L::PLUS, L::DASH, L::DOT, L::SLASH].include?(_1) }.freeze
72
+ UNIT_SIZE = { second: 0, minute: 0, hour: 0, day: 2, business_day: 2, week: 3, fortnight: 3, month: 5,
73
+ quarter: 6, season: 6, year: 7, decade: 8, century: 9 }.freeze
74
+
75
+ def self.parse(tokens) = new(tokens).phrase
76
+
77
+ FIRST = 16
78
+
79
+ # The longest prefix of the tokens that is a phrase (or with duration: true, a duration), as [tree, its
80
+ # length in Array entries], or nil. A failure says which token the parser stuck at; a longer prefix gets
81
+ # no further, so the next try ends before it. Each try is a new Array: a lookahead past the end must
82
+ # read nil. A long run of words is tried on its first FIRST tokens before all of them: most fail well
83
+ # inside that, as a longer prefix would, and the parse of a long run costs its length. spent: an Array
84
+ # whose first entry counts the entries parsed.
85
+ def self.longest(tokens, duration: false, spent: nil)
86
+ if tokens.size > FIRST * S
87
+ spent[0] += FIRST * S if spent
88
+ g = new(tokens[0, FIRST * S])
89
+ tree = duration ? g.duration : g.phrase
90
+ tokens = tokens[0, g.stuck] if Failure === tree && g.stuck < (FIRST - 4) * S
91
+ end
92
+ until tokens.empty?
93
+ spent[0] += tokens.size if spent
94
+ g = new(tokens)
95
+ tree = duration ? g.duration : g.phrase
96
+ return [tree, tokens.size] unless Failure === tree
97
+ stop = g.stuck < tokens.size ? g.stuck : tokens.size - S
98
+ tokens = tokens[0, stop]
99
+ end
100
+ end
101
+
102
+ def initialize(tokens)
103
+ @t = tokens
104
+ @n = tokens.size
105
+ @i = 0
106
+ @approx = false
107
+ end
108
+
109
+ attr_reader :stuck
110
+
111
+ def phrase
112
+ return failure(:incomplete) if @n == 0
113
+ node = top
114
+ return node if Failure === node
115
+ return failure(:conflict) if @i < @n
116
+ return failure(:incomplete) if Unit === node
117
+ @approx ? Approx.new(node) : node
118
+ end
119
+
120
+ private
121
+
122
+ def top
123
+ w = @t[@i + 1]
124
+ if w == :anytime && @t[@i] == L::WORD && OPEN[@t[@i + S + 1]] # D48: "anytime after 2014"
125
+ @i += S
126
+ w = @t[@i + 1]
127
+ end
128
+ if ((dir = OPEN[w]) || starting?(@i)) && @i + S < @n
129
+ dir ||= :from
130
+ @i += S
131
+ @i += S if dir == :from && @t[@i + 1] == :from # "starting from 1/1/2016"
132
+ # D48: "after 2 days, 3 hours and 1 minute" is a shift, as "in 2 days, 3 hours and 1 minute"
133
+ if dir == :after && @t[@i] == L::NUM && unit_at(@i + S) && @t[@i + 2 * S] == L::NUM && unit_at(@i + 3 * S)
134
+ q = quantity
135
+ return shifts(1, q, more_units(q), nil)
136
+ end
137
+ node = body
138
+ return node if Failure === node
139
+ return between(dir, node) if @but
140
+ # "after 3 hours" is a shift, not an open end
141
+ return Shift.new(node.count, node.unit, nil) if Unit === node && node.count && dir == :after
142
+ return failure(:incomplete) if Unit === node
143
+ Open.new(dir, node)
144
+ elsif @t[@i] == L::WORD && w == :around
145
+ @i += S
146
+ @approx = true
147
+ body
148
+ else
149
+ (w == :for && for_from) || open_after(body(w))
150
+ end
151
+ end
152
+
153
+ def body(w = @t[@i + 1])
154
+ open = @t[@i] == L::WORD && (w == :between || w == :from) # the kind first: Integer == Symbol is slow
155
+ if open
156
+ start = @i
157
+ @i += S
158
+ end
159
+ fs = @i
160
+ @mid = nil
161
+ from = item(open)
162
+ return from if Failure === from
163
+ if open
164
+ return lasting(from) if w == :from && @t[@i + 1] == :for && @t[@i] == L::WORD
165
+ return @i = start && failure(:incomplete) unless separator?(@t[@i + 1] == :and)
166
+ elsif @mid
167
+ return failure(:incomplete) unless separator?(@mid == :between)
168
+ else
169
+ # D48: "today and tomorrow": two days in a row are both of them
170
+ return from unless separator?(DayRef === from && @t[@i + S] == L::DAY && @t[@i + S + 1] == from.offset + 1)
171
+ end
172
+ fe = @i
173
+ @but = @t[@i + 1] == :butbefore
174
+ @i += S
175
+ ts = @i
176
+ to = day_and_year(from, ts)
177
+ return to if to
178
+ to = item(true)
179
+ return to if Failure === to
180
+ range(from, to, fs, fe, ts)
181
+ end
182
+
183
+ def separator?(and_ok)
184
+ return false if @i >= @n
185
+ k = @t[@i]
186
+ k == L::DASH || (k == L::WORD && (RANGE[@t[@i + 1]] || (and_ok && @t[@i + 1] == :and))) || k == L::SLASH
187
+ end
188
+
189
+ # One constituent after another, then nested by size. Most items are one or two atoms, so the lists of
190
+ # atoms and of where each starts are made only for a third.
191
+ def item(loose)
192
+ last = nil
193
+ a1 = s1 = a2 = s2 = nil
194
+ atoms = nil
195
+ starts = nil
196
+ dates = 0
197
+ zone = nil
198
+ numeric = false
199
+ at = false
200
+ signed = false
201
+ while @i < @n
202
+ k = @t[@i]
203
+ v = @t[@i + 1]
204
+ a = nil
205
+ if !ITEM_SPECIAL[k]
206
+ # a token that only starts an atom, the commonest case: none of the tests below apply
207
+ elsif k == L::AT
208
+ @i += S
209
+ at = true
210
+ next
211
+ elsif k == L::WORD && (v == :the || v == :on || (v == :ad && Date === last)) # D44: "1973 A.D."
212
+ @i += S
213
+ next
214
+ elsif k == L::WORD && v == :of && Clock === last && day_at?(@i + S)
215
+ @i += S # D48: "from 5 to 6pm of April 22", as "on April 22"
216
+ next
217
+ elsif k == L::WORD && (v == :for || v == :during)
218
+ break if v == :for && !for_when?(@i + S) # D48: "from 4pm for 30 minutes": a length; lasting reads it
219
+ @i += S # D48: "for 9am", "for the past month", "during March": the time it is for
220
+ next
221
+ elsif k == L::WORD && v == :around
222
+ @approx = true # D14: "tomorrow around 10am", "3pm approximately", "11ish"
223
+ @i += S
224
+ next
225
+ elsif k == L::WORD && (v == :from || v == :between) && last && !@mid
226
+ # D44: "tomorrow from 9 to 5", "friday between 1 and 4pm": the range is within what came before
227
+ @mid = v
228
+ @i += S
229
+ next
230
+ elsif k == L::ZONE
231
+ @i += S
232
+ next if zone # D44: "GMT+0800 (CST)", "-0500 (CDT)": a second zone labels the first
233
+ zone = v
234
+ # "GMT-1", "UTC +8"; with spaces both sides it is a range ("15:00 GMT - 18:00 GMT")
235
+ if Integer === zone && @i + S < @n && (@t[@i] == L::PLUS || @t[@i] == L::DASH) &&
236
+ (@t[@i + 3] == @t[@i - 1] || @t[@i + S + 3] == @t[@i + 4]) && (off = offset(@i + S))
237
+ zone += @t[@i] == L::PLUS ? off : -off
238
+ return failure(:invalid_date, @i + S) if zone.abs >= 86_400 # D18: "JST+23:00"
239
+ numeric = true
240
+ @i += 2 * S
241
+ end
242
+ next
243
+ elsif (k == L::PLUS || k == L::DASH) && @i + S < @n && signed?(last)
244
+ signed = true
245
+ break
246
+ elsif (k == L::PLUS || (k == L::DASH && ((machine?(last) && @t[@i + 4] == @t[@i + S + 3]) || dash_offset?(last)))) && @i + S < @n &&
247
+ (off = offset(@i + S))
248
+ zone = k == L::PLUS ? off : -off
249
+ numeric = true
250
+ @i += 2 * S
251
+ next
252
+ elsif (k == L::DASH || k == L::DOT) && Date === last && last.sep && last.day && @t[@i + S] == L::CLOCK &&
253
+ @t[@i + 3] == @t[@i - 1] && @t[@i + 4] == @t[@i + S + 3]
254
+ @i += S # D44: "05/31/2024-14:15": a clock joined to a numeric date by a dash is its time, as after a T; D48: or a dot
255
+ next
256
+ elsif k == L::DASH && Date === last && last.sep && last.year && glued_clock?(@i)
257
+ # D48: "05/31/2024:14:15": the colon after a year lexes as a dash, so the clock comes as two numbers
258
+ start = @i + S
259
+ a = Clock.new(@t[@i + S + 1], @t[@i + 3 * S + 1], 0, nil, nil, true, false)
260
+ @i += 4 * S
261
+ elsif k == L::SLASH && machine?(last) && @t[@i + S] == L::NUM && @t[@i + S + 2] & L::DIGITS == 4
262
+ break # D48: "2015-03-28 17:00:00/2015-03-29 21:00:00", an ISO 8601 interval
263
+ elsif k == L::DASH || (k == L::WORD && (RANGE[v] || v == :and || v == :or || v == :orlater || v == :orearlier))
264
+ break
265
+ elsif k == L::WORD && (v == :before || v == :after) && last && clock_at?(@i + S)
266
+ break # D48: "tomorrow after 4pm", clipped by open_after
267
+ end
268
+ unless a
269
+ start = @i
270
+ a = atom(loose || at || !last.nil?)
271
+ return a if Failure === a
272
+ end
273
+ if atoms
274
+ atoms << a
275
+ starts << start
276
+ elsif a2
277
+ atoms = [a1, a2, a]
278
+ starts = [s1, s2, start]
279
+ elsif a1
280
+ a2 = a
281
+ s2 = start
282
+ else
283
+ a1 = a
284
+ s1 = start
285
+ end
286
+ last = a
287
+ dates += 1 if Date === a
288
+ at = false
289
+ end
290
+ return signed_shifts(nil) if signed && last.nil?
291
+ return failure(:incomplete) if at || last.nil?
292
+ node = last
293
+ if a2 && !atoms && dates < 2
294
+ node = pair(a1, s1, a2, s2)
295
+ elsif a2
296
+ atoms ||= [a1, a2]
297
+ starts ||= [s1, s2]
298
+ merge_year(atoms, starts) if dates > 1
299
+ node = combine(atoms, starts)
300
+ end
301
+ return node if Failure === node
302
+ node = Zoned.new(node, zone, numeric) if zone
303
+ signed ? signed_shifts(node) : node
304
+ end
305
+
306
+ # "for" before a time or a period, not a length (E2)
307
+ def for_when?(i)
308
+ k = @t[i]
309
+ clock_at?(i) || (k == L::WORD && @t[i + 1] == :the) || k == L::REL ||
310
+ (k == L::ART && @t[i + S] == L::UNIT && clock_word?(i + 2 * S))
311
+ end
312
+
313
+ def day_at?(i)
314
+ k = @t[i]
315
+ k == L::MONTH || k == L::DAY || (k == L::NUM && @t[i + S] == L::SLASH)
316
+ end
317
+
318
+ def clock_at?(i)
319
+ k = @t[i]
320
+ k == L::CLOCK || k == L::NAMED || (k == L::NUM && @t[i + S] == L::MER)
321
+ end
322
+
323
+ def glued_clock?(i)
324
+ t = @t
325
+ t[i + S] == L::NUM && t[i + 2 * S] == L::DASH && t[i + 3 * S] == L::NUM && t[i - 1] == t[i + 3] && t[i + 4] == t[i + S + 3] &&
326
+ t[i + S + 4] == t[i + 2 * S + 3] && t[i + 2 * S + 4] == t[i + 3 * S + 3] && t[i + S + 2] & L::DIGITS == 2 &&
327
+ t[i + 3 * S + 2] & L::DIGITS == 2 && t[i + S + 1] <= 23 && t[i + 3 * S + 1] <= 59
328
+ end
329
+
330
+ def machine?(a)
331
+ a = a.inner if Within === a
332
+ Clock === a && (a.seconds || a.h24)
333
+ end
334
+
335
+ # D42: "15:45 -0800", "11:00 -03:00": after a clock, a dash with a space before it and none after, then
336
+ # four digits or hh:mm, is an offset; "3:00 - 5:00" and "9-5" stay ranges
337
+ def dash_offset?(last)
338
+ last = last.inner if Within === last # "2010-09-12 3:12pm -0700"
339
+ return false unless Clock === last && @i + S < @n && @t[@i + 3] > @t[@i - 1] && @t[@i + S + 3] == @t[@i + 4]
340
+ k = @t[@i + S]
341
+ (k == L::NUM && @t[@i + S + 2] & L::DIGITS == 4) || (k == L::CLOCK && @t[@i + S + 2] & L::DIGITS == 2)
342
+ end
343
+
344
+ # "+01:00", "-0400", "+1"; D18: up to 23:59
345
+ def offset(i)
346
+ k = @t[i]
347
+ v = @t[i + 1]
348
+ if k == L::CLOCK && v % 100 == 0 then hm(v / 10_000, v / 100 % 100)
349
+ elsif k == L::NUM && @t[i + 2] & L::DIGITS == 4 then hm(v / 100, v % 100)
350
+ elsif k == L::NUM && @t[i + 2] & L::DIGITS <= 2 && v <= 14 then v * 3600
351
+ end
352
+ end
353
+
354
+ def hm(h, m) = h <= 23 && m <= 59 ? h * 3600 + m * 60 : nil
355
+
356
+ # Points at token index i, or at the end of the text when the tokens ran out
357
+ def failure(code, i = @i)
358
+ @stuck = i > @i ? i : @i
359
+ if i < @n
360
+ Failure.new(code, @t[i + 3], @t[i + 4])
361
+ else
362
+ stop = @n == 0 ? 0 : @t[@n - 1]
363
+ Failure.new(code, stop, stop)
364
+ end
365
+ end
366
+ end
367
+ end
368
+
369
+ require_relative "grammar/nesting"
370
+ require_relative "grammar/ranges"
371
+ require_relative "grammar/words"
372
+ require_relative "grammar/ordinals"
373
+ require_relative "grammar/clock_words"
374
+ require_relative "grammar/numbers"
375
+ require_relative "grammar/compact"
376
+ require_relative "grammar/shifts"
377
+ require_relative "grammar/duration"
378
+ require_relative "grammar/rule"