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,378 @@
1
+ module Acute
2
+ # Atoms led by digits or a number word: clocks, numeric and ISO dates, years
3
+ class Grammar
4
+ MULTIPLIERS = { hundred: 100, thousand: 1000, million: 1_000_000 }.freeze
5
+
6
+ private
7
+
8
+ # D47: a count said past ninety-nine, "two million eight hundred and sixty seven thousand five hundred and
9
+ # thirty two", "a hundred": its value and the index of its last token, or nil without a hundred, thousand or
10
+ # million straight after the first number
11
+ def spoken(i)
12
+ t = @t
13
+ j = i + S
14
+ # D48: "hundred decades", as "a hundred"; not after a number, which would have read it ("two thousand
15
+ # three thousand days")
16
+ if t[i] == L::WORD && MULTIPLIERS[t[i + 1]] && (i == 0 || t[i - S] != L::NUM)
17
+ group = 1
18
+ j = i
19
+ elsif !(t[j] == L::WORD && MULTIPLIERS[t[j + 1]]) then return
20
+ elsif t[i] == L::ART then group = 1
21
+ elsif t[i] == L::NUM && t[i + 1].between?(1, 99) then group = t[i + 1]
22
+ else return
23
+ end
24
+ total = 0
25
+ hundred = false
26
+ below = nil # each thousand or million is smaller than the one before
27
+ last = i
28
+ while j < @n
29
+ k = t[j]
30
+ m = k == L::WORD && MULTIPLIERS[t[j + 1]]
31
+ if m == 100
32
+ break if hundred || group < 1 || group > 99
33
+ group *= 100
34
+ hundred = true
35
+ elsif m
36
+ break if group < 1 || (below && m >= below)
37
+ total += group * m
38
+ group = 0
39
+ hundred = false
40
+ below = m
41
+ elsif k == L::WORD && t[j + 1] == :and && t[j + S] == L::NUM && t[last] == L::WORD
42
+ j += S
43
+ next
44
+ elsif k == L::NUM && t[last] == L::WORD && group % 100 == 0 && t[j + 1].between?(1, 99) &&
45
+ t[j + 2] & L::WORDY != 0
46
+ group += t[j + 1]
47
+ else
48
+ break
49
+ end
50
+ last = j
51
+ j += S
52
+ end
53
+ [total + group, last]
54
+ end
55
+
56
+ def number_atom(loose)
57
+ t = @t
58
+ i = @i
59
+ v = t[i + 1]
60
+ flags = t[i + 2]
61
+ digits = flags & L::DIGITS
62
+ nk = @t[i + S]
63
+ if nk.nil?
64
+ # the last token: none of the forms below
65
+ elsif nk == L::SLASH || nk == L::DOT
66
+ return dotted_clock(i) if nk == L::DOT && dotted_clock?(i)
67
+ return dashed_month(v, digits) if @t[i + 2 * S] == L::MONTH && joined?(i + S) # "25/Sep/2003"
68
+ # "1/2 hour", "2.5 hours"; not "22/0 y"
69
+ if @t[i + 3 * S] == L::UNIT && @t[i + 2 * S] == L::NUM && (q = quantity)
70
+ return shift_tail(q)
71
+ end
72
+ return numeric_date(nk == L::SLASH ? :slash : :dot) if @t[i + 2 * S] == L::NUM
73
+ elsif nk == L::DASH
74
+ return dashed_month(v, digits) if @t[i + 2 * S] == L::MONTH && joined?(i + S)
75
+ if @t[i + 2 * S] == L::NUM
76
+ # "5-2020": a month and its year
77
+ f = t[i + 2 * S + 2]
78
+ if digits <= 2 && v >= 1 && v <= 12 && f & L::DIGITS == 4 && f & L::ZERO == 0 && joined?(i + S)
79
+ @i += 3 * S
80
+ return Date.new(t[i + 2 * S + 1], v, nil, nil, false)
81
+ end
82
+ # D44: "2014-2018" is a range of years; "0900-1700" and "2010-2015-86" are not
83
+ return iso_date if digits == 4 && (f & L::DIGITS != 4 || flags & L::ZERO != 0 || @t[i + 3 * S] == L::DASH)
84
+ w = t[i + 2 * S + 1]
85
+ # D44: not before a month's name, where they are its days ("12-14 June"); D48: nor before a dotted
86
+ # month ("12-14.06.2021")
87
+ if @t[i + 3 * S] == L::DASH || (@t[i + 3 * S] != L::MONTH && @t[i + 3 * S] != L::DOT && ((w > 12 && w <= 31 && v <= 12) || v > 12 && v <= 31 && w <= 12))
88
+ return numeric_date(:dash)
89
+ end
90
+ # D48: "05-06 at noon": two padded numbers before a time are a date, not a range of hours
91
+ k = @t[i + 3 * S]
92
+ if (k == L::AT || k == L::CLOCK || k == L::NAMED) && digits == 2 && f & L::DIGITS == 2 && (flags | f) & L::ZERO != 0 && joined?(i + S)
93
+ return numeric_date(:dash)
94
+ end
95
+ elsif digits == 4 && @t[i + 2 * S] == L::UNIT && joined?(i + S) && (y = week_year(v, i + S))
96
+ return y
97
+ end
98
+ elsif nk == L::MONTH
99
+ return day_month(v, i + S) if digits <= 2 && v >= 1 && v <= 31
100
+ elsif nk == L::UNIT
101
+ return y if digits == 4 && t[i + 4] == t[i + S + 3] && (y = week_year(v, i))
102
+ # D5: "1530h", "1530 hrs" are times
103
+ if digits == 4 && t[i + S + 1] == :hour && @t[i + 2 * S] != L::WORD
104
+ @i += 2 * S
105
+ return four_digits(v, flags, i)
106
+ end
107
+ if digits <= 2 && v <= 23 && t[i + S + 1] == :hour && t[i + S + 4] - t[i + 4] == 1 && t[i + 4] == t[i + S + 3] &&
108
+ (c = h_clock(v, i, loose))
109
+ return c
110
+ end
111
+ elsif (nk == L::WDAY || nk == L::WEEKEND || nk == L::HOLIDAY || nk == L::PART || nk == L::SEASON) && counted?(i + S)
112
+ @i += S
113
+ return counted(v)
114
+ elsif nk == L::ORD
115
+ q = quantity
116
+ return shift_tail(q) if q
117
+ elsif nk == L::WORD
118
+ return clock_words(v) if minutes_to?(i)
119
+ return shift_tail(q) if MULTIPLIERS[t[i + S + 1]] && (q = quantity)
120
+ if t[i + S + 1] == :ad && flags & L::WORDY == 0 && v >= 1 # D44: "14 a.d." is the year 14
121
+ @i += 2 * S
122
+ return Date.new(v, nil, nil, nil, false)
123
+ end
124
+ return Date.new(buddhist(v), nil, nil, nil, false) if buddhist?(i)
125
+ # D48: "18q4": a year's last two digits, then its quarter, all one word
126
+ if t[i + S + 1] == :q && digits == 2 && t[i + 2 * S] == L::NUM && t[i + 2 * S + 1].between?(1, 4) && t[i + 4] == t[i + S + 3] &&
127
+ t[i + S + 4] == t[i + 2 * S + 3]
128
+ @i += 3 * S
129
+ return Within.new(Nth.new(t[i + 2 * S + 1], Unit.new(:quarter, nil)), Date.new(v, nil, nil, nil, true))
130
+ end
131
+ return w if flags & L::WORDY != 0 && (w = said(v, i))
132
+ # D44: "15 of february", "20 of next month": a number of a month is its day, as an ordinal is; not
133
+ # "5 to 6 of April 22", where the month has its own
134
+ if t[i + S + 1] == :of && digits <= 2 && v >= 1 && v <= 31
135
+ mk = @t[i + 2 * S]
136
+ return day_month(v, i + 2 * S) if mk == L::MONTH && !(@t[i + 3 * S] == L::NUM && @t[i + 3 * S + 2] & L::DIGITS <= 2)
137
+ if mk == L::REL && @t[i + 3 * S] == L::UNIT && @t[i + 3 * S + 1] == :month
138
+ @i += S
139
+ return of(Date.new(nil, nil, v, nil, false))
140
+ end
141
+ end
142
+ if t[i + S + 1] == :oclock
143
+ if v == 24 && @t[i + 2 * S] != L::MER # D44: as "24:00"
144
+ @i += 2 * S
145
+ return Clock.new(24, 0, 0, nil, nil, true, false)
146
+ end
147
+ @i += S # read from the o'clock, so a meridiem after it is the clock's: "11 o'clock pm"
148
+ return number(v, flags, true)
149
+ end
150
+ elsif nk == L::REL
151
+ # D44: "2 upcoming weeks", "10 previous weeks" are "next 2 weeks", "previous 10 weeks"
152
+ if t[i + 2 * S] == L::UNIT && t[i + 2 * S + 2] & L::PLURAL != 0
153
+ @i += 3 * S
154
+ return Rel.new(t[i + S + 1], Unit.new(t[i + 2 * S + 1], v))
155
+ end
156
+ return clock_words(v) if t[i + S + 1] == :past && flags & L::WORDY != 0 || (v <= 30 && (@t[i + 2 * S] == L::NUM || @t[i + 2 * S] == L::NAMED))
157
+ elsif nk == L::DECADE
158
+ # "nineteen seventies"
159
+ if flags & L::WORDY != 0 && t[i + S + 2] & L::WORDY != 0 && v >= 10
160
+ @i += 2 * S
161
+ return Years.new(:decade, v * 100 + t[i + S + 1], false)
162
+ end
163
+ elsif nk == L::NUM
164
+ return w if flags & L::WORDY != 0 && (w = said(v, i))
165
+ return spaced_date(v, digits) if spaced_date?(i, digits)
166
+ # D44: "at 3 15", "at 12 05": after "at", an hour then two digits are its minutes, as said aloud
167
+ if digits <= 2 && v <= 23 && i >= S && t[i - S] == L::AT && t[i + S + 2] & (L::DIGITS | L::WORDY) == 2 && t[i + S + 1] <= 59
168
+ @i += S
169
+ return written(v * 10_000 + t[i + S + 1] * 100, flags & L::ZERO)
170
+ end
171
+ end
172
+ if nk == L::UNIT || nk == L::SLASH
173
+ # D44: "20 minutes past 3pm", "ten minutes to three"
174
+ return clock_words(v) if t[i + S + 1] == :minute && v.between?(1, 59) && clock_word?(i + 2 * S) && hour?(i + 3 * S)
175
+ q = quantity
176
+ return shift_tail(q) if q
177
+ end
178
+ loose ||= @n == S + i || nk == L::DASH || (nk == L::WORD && (RANGE[t[i + S + 1]] || t[i + S + 1] == :and)) ||
179
+ part_next?(i + S) || (@n == 2 * S + i && t[i + S + 1] == :around) # "11ish"
180
+ number(v, flags, loose)
181
+ end
182
+
183
+ # "6 in the morning", "12 at night", D44: "7 tonight", "8 this evening", "9 tomorrow", "7, this morning":
184
+ # a part of day or a day after a bare number makes it an hour; D46: so does "on" and a named day,
185
+ # "5 on may 28", "5 on friday", as "at 5 on may 28"
186
+ def part_next?(j)
187
+ k = @t[j]
188
+ return true if k == L::PART || k == L::DAY || (k == L::REL && @t[j + S] == L::PART)
189
+ if k == L::WORD && @t[j + 1] == :on
190
+ k = @t[j + S]
191
+ return k == L::MONTH || k == L::WDAY || k == L::HOLIDAY
192
+ end
193
+ return false unless k == L::AT || (k == L::WORD && @t[j + 1] == :in)
194
+ j += S
195
+ j += S if @t[j + 1] == :the
196
+ @t[j] == L::PART
197
+ end
198
+
199
+ # D13: the year of "2026-W42", "2026W01"; the week follows as its own atom
200
+ def week_year(v, j)
201
+ return unless @t[j + S] == L::UNIT && @t[j + S + 1] == :week && week_number?(j + 2 * S)
202
+ @i = j + S
203
+ Date.new(v, nil, nil, nil, false)
204
+ end
205
+
206
+ # "10/23", "23/10/2026", "11.9.2003", "8-15": the numbers as written; resolve picks the order
207
+ def numeric_date(sep)
208
+ t = @t
209
+ i = @i
210
+ a = t[i + 1]
211
+ b = t[i + 2 * S + 1]
212
+ @i += 3 * S
213
+ year = nil
214
+ short = false
215
+ if @t[@i] == t[i + S] && @t[@i + S] == L::NUM
216
+ digits = t[@i + S + 2] & L::DIGITS
217
+ # D37: year first only when the first number has four digits: "2026/11/23"; D44: or is marked as a
218
+ # year by an apostrophe, or is past 31: "'69-5-31", "78-12-22"
219
+ f = t[i + 2]
220
+ if digits <= 2 && (f & L::DIGITS == 4 || (f & L::DIGITS == 2 && (f & L::SHORT != 0 || a > 31)))
221
+ @i += 2 * S
222
+ return Date.new(a, b, t[@i - S + 1], nil, f & L::DIGITS == 2)
223
+ end
224
+ return failure(:invalid_date, @i + S) unless digits == 2 || digits == 4
225
+ year = t[@i + S + 1]
226
+ short = digits == 2
227
+ @i += 2 * S
228
+ elsif (d = t[i + 2 * S + 2] & L::DIGITS) == 4 || t[i + 2] & L::DIGITS == 4
229
+ # D44: "11/2026", "2013/12": a month and its year
230
+ return d == 4 ? Date.new(b, a, nil, nil, false) : Date.new(a, b, nil, nil, false) if (d == 4 ? a : b).between?(1, 12)
231
+ elsif @t[@i] == L::NUM && short_year?(@t[@i + 2]) # D44: "08/12,15", as "3 jan 10"
232
+ year = @t[@i + 1]
233
+ short = true
234
+ @i += S
235
+ end
236
+ Date.new(year, a, b, sep, short)
237
+ end
238
+
239
+ # "23 oct", "23 oct 2026"
240
+ def day_month(day, i)
241
+ @i = i + S
242
+ dated(@t[i + 1], day)
243
+ end
244
+
245
+ # D44: a dash or slash with no space either side joins the parts of one written date: "20-Apr",
246
+ # "30-Mar-11", "2003-Sep-25", "99-Jan-08", "25/Sep/2003". A first number past 31 is the year.
247
+ def dashed_month(v, digits)
248
+ m = @t[@i + 2 * S + 1]
249
+ @i += 3 * S
250
+ if digits == 4 || v > 31
251
+ day = nil
252
+ if joined?(@i) && @t[@i + S] == L::NUM && @t[@i + S + 2] & L::DIGITS <= 2 && @t[@i + S + 1].between?(1, 31)
253
+ day = @t[@i + S + 1]
254
+ @i += 2 * S
255
+ end
256
+ return Date.new(v, m, day, nil, digits == 2)
257
+ end
258
+ return failure(:invalid_date, @i - 3 * S) if v < 1
259
+ dashed_year(m, v) || dated(m, v)
260
+ end
261
+
262
+ # The "-1999" or "-99" that ends a dashed date
263
+ def dashed_year(m, day)
264
+ return unless joined?(@i) && @t[@i + S] == L::NUM
265
+ digits = @t[@i + S + 2] & L::DIGITS
266
+ return unless digits == 2 || digits == 4
267
+ @i += 2 * S
268
+ Date.new(@t[@i - S + 1], m, day, nil, digits == 2)
269
+ end
270
+
271
+ # A dash, or a slash, at token index i with no space either side
272
+ def joined?(i) = (@t[i] == L::DASH || @t[i] == L::SLASH) && i >= S && @t[i - 1] == @t[i + 3] && @t[i + 4] == @t[i + S + 3]
273
+
274
+ # "oct", "oct 23", "oct 23rd, 2026", "may 2010", "Oct-2", "Sep-25-2003", "Sep-2012", "Sep/25/2003"
275
+ def month_atom
276
+ m = @t[@i + 1]
277
+ @i += S
278
+ if joined?(@i) && @t[@i + S] == L::NUM
279
+ f = @t[@i + S + 2]
280
+ v = @t[@i + S + 1]
281
+ if f & L::DIGITS == 4 && f & L::ZERO == 0
282
+ @i += 2 * S
283
+ return Date.new(v, m, nil, nil, false)
284
+ elsif f & L::DIGITS <= 2 && v >= 1 && v <= 31
285
+ @i += 2 * S
286
+ return dashed_year(m, v) || dated(m, v)
287
+ end
288
+ end
289
+ day = nil
290
+ k = @t[@i]
291
+ if (k == L::NUM || k == L::ORD) && @t[@i + 2] & (L::DIGITS | L::SHORT) <= 2 && @t[@i + 1] >= 1 && @t[@i + 1] <= 31 &&
292
+ @t[@i + S] != L::MER && @t[@i + S] != L::UNIT
293
+ day = @t[@i + 1]
294
+ @i += S
295
+ end
296
+ dated(m, day)
297
+ end
298
+
299
+ # The date with its optional year after it: four digits, "of 2026", "'26", or after a day two digits that
300
+ # are not a clock ("3 jan 10", "may 27 79") (D23)
301
+ def dated(m, day)
302
+ @i += S if @t[@i + 1] == :of && @t[@i + S] == L::NUM
303
+ # D48: "march of next year" as "march next year"
304
+ @i += S if @t[@i + 1] == :of && @t[@i] == L::WORD && @t[@i + S] == L::REL && @t[@i + 2 * S + 1] == :year && @t[@i + 2 * S] == L::UNIT
305
+ @i += S if day && @t[@i] == L::DOT && @t[@i + S] == L::NUM && @t[@i + S + 2] & L::DIGITS == 4 # D48: "Sep.25.2003"
306
+ f = @t[@i + 2]
307
+ if @t[@i] == L::NUM && @t[@i + S + 1] == :ad && @t[@i + S] == L::WORD && f & L::WORDY == 0 # D44: "10 August 88 AD"
308
+ @i += 2 * S
309
+ return Date.new(@t[@i - 2 * S + 1], m, day, nil, false)
310
+ end
311
+ return Date.new(buddhist(@t[@i + 1]), m, day, nil, false) if buddhist?(@i)
312
+ # D44: "dec 79", "may 97": with no day, two digits past 31 are the year
313
+ if @t[@i] == L::NUM && ((f & L::DIGITS == 4 && f & L::ZERO == 0) || f & L::SHORT != 0 ||
314
+ ((day || @t[@i + 1] > 31) && short_year?(f)))
315
+ @i += S
316
+ return Date.new(@t[@i - S + 1], m, day, nil, f & L::DIGITS != 4)
317
+ end
318
+ Date.new(nil, m, day, nil, false)
319
+ end
320
+
321
+ # D48: "2555 BE", a year of the Buddhist era, 543 years ahead; only from 2400, so "by 2025 be ready" is not one
322
+ def buddhist?(i) = @t[i + S + 1] == :be && @t[i + S] == L::WORD && @t[i] == L::NUM && @t[i + 2] & L::DIGITS == 4 && @t[i + 1] >= 2400
323
+
324
+ def buddhist(v)
325
+ @i += 2 * S
326
+ v - 543
327
+ end
328
+
329
+ def short_year?(f)
330
+ return false unless f & L::DIGITS == 2
331
+ k = @t[@i + S]
332
+ k != L::MER && k != L::UNIT && k != L::NAMED && k != L::ART && k != L::DASH &&
333
+ !(k == L::WORD && ((w = @t[@i + S + 1]) == :oclock || RANGE[w] || w == :and))
334
+ end
335
+
336
+ def named(hour) = Clock.new(hour, 0, 0, nil, hour == 12 ? :noon : :midnight, false, false)
337
+
338
+ # loose: a bare number counts as an hour (after "at" or another constituent, in a range, or alone)
339
+ def number(v, flags, loose)
340
+ digits = flags & L::DIGITS
341
+ zero = flags & L::ZERO != 0
342
+ at = @i
343
+ @i += S
344
+ return Date.new(v, nil, nil, nil, true) if flags & L::SHORT != 0 # "halloween '11"
345
+ # D48: "the 24", "the 3 of next month": after "the", a bare number ending the phrase is a day of the month
346
+ if at >= S && @t[at - S + 1] == :the && @t[at - S] == L::WORD && digits <= 2 && !zero && v >= 1 && v <= 31 &&
347
+ (@i >= @n || (@t[@i + 1] == :of && @t[@i] == L::WORD))
348
+ return of(Date.new(nil, nil, v, nil, false))
349
+ end
350
+ @i += 2 * S if in_the_meridiem?
351
+ if @t[@i + 1] == :around && @t[@i + S] == L::MER && @t[@i] == L::WORD && @t[at + 4] == @t[@i + 3] # D48: "3ish pm"
352
+ @approx = true
353
+ @i += S
354
+ end
355
+ kind = @t[@i]
356
+ if (kind == L::MER || (kind == L::ART && @t[at + 4] == @t[@i + 3])) && digits <= 2 # "8a"
357
+ return failure(:invalid_date, at) if (v < 1 && !midnight_am?(kind)) || v > 12
358
+ meridiem = kind == L::ART || @t[@i + 1] == 0 ? :am : :pm
359
+ @i += S
360
+ Clock.new(v, 0, 0, meridiem, nil, false, false)
361
+ elsif kind == L::NAMED && v == 12 && digits <= 2
362
+ @i += S
363
+ named(@t[@i - S + 1])
364
+ elsif (digits == 3 && at >= S && (@t[at - S] == L::AT || @t[at - S] == L::PART)) || ((digits == 3 || digits == 4) && kind == L::MER)
365
+ three_digits(v, at)
366
+ elsif digits == 4
367
+ four_digits(v, flags, at)
368
+ elsif digits == 8
369
+ compact_date(v, at)
370
+ elsif digits <= 2 && loose && kind != L::NUM
371
+ return failure(:invalid_date, at) if v > 23
372
+ Clock.new(v, 0, 0, nil, nil, zero, false)
373
+ else
374
+ failure(:incomplete, at)
375
+ end
376
+ end
377
+ end
378
+ end
@@ -0,0 +1,101 @@
1
+ module Acute
2
+ # Atoms led by an ordinal, and what an atom is "of"
3
+ class Grammar
4
+ private
5
+
6
+ # "3rd", "the 3rd of may", "second tuesday of march", "1st day of next month", "first of next month"
7
+ def range_word?(j) = @t[j] == L::DASH || (@t[j] == L::WORD && RANGE[@t[j + 1]])
8
+
9
+ def day_before?(j) = (@t[j] == L::ORD || @t[j] == L::NUM) && j >= S && @t[j - S] == L::MONTH
10
+
11
+ def ordinal_atom
12
+ n = @t[@i + 1]
13
+ @i += S
14
+ k = @t[@i]
15
+ # D12: "the 15th century", "the 3rd century A.D."
16
+ if k == L::UNIT && @t[@i + 1] == :century && @t[@i + S + 1] != :of && n >= 1
17
+ @i += S
18
+ @i += S if @t[@i + 1] == :ad
19
+ return Years.new(:century, (n - 1) * 100, false)
20
+ end
21
+ return counted(n) if n >= 1 && counted?(@i)
22
+ return closest(n) if n >= 1 && k == L::WORD && @t[@i + 1] == :closest
23
+ # D48: "first to last day of 2008" is the whole of it
24
+ if n == 1 && k == L::WORD && @t[@i + 1] == :to && @t[@i + S + 1] == :last && @t[@i + 2 * S + 1] == :day &&
25
+ @t[@i + 3 * S + 1] == :of && @t[@i + 3 * S] == L::WORD
26
+ @i += 4 * S
27
+ return atom(true)
28
+ end
29
+ # D44: "the first half of 2027" is its January to June; "the first 9 months of 1997" its first nine
30
+ if n <= 2 && k == L::WORD && @t[@i + 1] == :half && @t[@i + S + 1] == :of
31
+ @i += S
32
+ return of(half(n))
33
+ end
34
+ if n == 1 && k == L::NUM && @t[@i + S] == L::UNIT && @t[@i + 2 * S + 1] == :of
35
+ q = quantity
36
+ node = of(q)
37
+ return Within === node ? Lasting.new(q, node.outer) : failure(:incomplete)
38
+ end
39
+ # D48: "the fifth last day of may", "second last friday": counted from the end
40
+ if k == L::REL && @t[@i + 1] == :last && n >= 1 && ((k2 = @t[@i + S]) == L::WDAY || k2 == L::UNIT || k2 == L::WEEKEND)
41
+ @i += S
42
+ k = k2
43
+ n = -n
44
+ end
45
+ # D48: "the 29th day of November" is November 29th, so a weekday may stand beside it
46
+ if k == L::UNIT && @t[@i + 1] == :day && @t[@i + S + 1] == :of && @t[@i + 2 * S] == L::MONTH && n >= 1 && n <= 31
47
+ @i += 3 * S
48
+ return dated(@t[@i - S + 1], n)
49
+ end
50
+ if k == L::WDAY || k == L::UNIT || k == L::WEEKEND
51
+ inner = k == L::WDAY ? Weekday.new(@t[@i + 1]) : k == L::UNIT ? Unit.new(@t[@i + 1], nil) : WEEKEND
52
+ @i += S
53
+ return of(Nth.new(n, inner))
54
+ elsif k == L::MONTH
55
+ @i += S
56
+ return dated(@t[@i - S + 1], n)
57
+ elsif @t[@i + 1] == :of && @t[@i + S] == L::MONTH
58
+ @i += 2 * S
59
+ return dated(@t[@i - S + 1], n)
60
+ end
61
+ return failure(:invalid_date, @i - S) if n < 1 || n > 31
62
+ # "first" alone is not a date; "the first", "first of next month", "from ninth till ...", "eighth to
63
+ # eleventh may" and "april 9th through seventeenth" are
64
+ if @t[@i - S + 2] & L::WORDY != 0 && @t[@i + 1] != :of && !(@i >= 2 * S && @t[@i - 2 * S] == L::WORD && BEFORE_DAY[@t[@i - 2 * S + 1]]) &&
65
+ !(@t[@i + S] == L::ORD && range_word?(@i)) && !(@i >= 3 * S && range_word?(@i - 2 * S) && day_before?(@i - 3 * S))
66
+ return failure(:incomplete, @i - S)
67
+ end
68
+ of(Date.new(nil, nil, n, nil, false))
69
+ end
70
+
71
+ def half(n) = Range.new(Date.new(nil, n * 6 - 5, nil, nil, false), Date.new(nil, n * 6, nil, nil, false))
72
+
73
+ # "... of <period>": "second tuesday of march", "tuesday of next week", "morning of xmas", "labor day of 2012"
74
+ def of(node)
75
+ return node unless @t[@i] == L::WORD && (@t[@i + 1] == :of || from_week?(node) || in_the_unit?(node))
76
+ @i += S
77
+ @i += S if @t[@i] == L::WORD && @t[@i + 1] == :the
78
+ # D42: "the first monday of the month" repeats monthly, so it is the coming one: next month's once passed
79
+ unit = @t[@i] == L::UNIT && !(@t[@i + S] == L::WORD && @t[@i + S + 1] == :after) # not "the week after next"
80
+ if unit && @t[@i + 1] == :month && (Date === node || (Nth === node && !(Unit === node.node && UNIT_SIZE[node.node.unit] >= 5)))
81
+ @i += S
82
+ return node
83
+ end
84
+ outer = unit ? this_unit : atom(true)
85
+ return outer if Failure === outer
86
+ Within.new(node, outer)
87
+ end
88
+
89
+ # D48: "sunday from last week" is "sunday of last week"
90
+ def from_week?(node) = @t[@i + 1] == :from && Weekday === node && @t[@i + S] == L::REL && @t[@i + 2 * S] == L::UNIT
91
+
92
+ # D48: "first monday in the month" as "of the month" (D42)
93
+ def in_the_unit?(node) = @t[@i + 1] == :in && Nth === node && @t[@i + S + 1] == :the && @t[@i + 2 * S] == L::UNIT
94
+
95
+ # "the month" in "the first monday of the month": the current one
96
+ def this_unit
97
+ @i += S
98
+ Rel.new(:this, Unit.new(@t[@i - S + 1], nil))
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,112 @@
1
+ module Acute
2
+ # A range's two ends, and the atoms they share
3
+ class Grammar
4
+ private
5
+
6
+ # The range's trailing atoms ("between 9:30 and 11:00 on thursday") belong to both ends, and so do its
7
+ # leading ones ("monday 8-9am"). D44: a bare day takes its month from the other end ("june 3-7", "13 to
8
+ # 15 july", "4 to 22 this month"), and a year said only at the end is the start's too.
9
+ def range(from, to, fs, fe, ts)
10
+ return Range.new(from, to) if Clock === from && Clock === to
11
+ # D48: "previous week - Monday": a dash between a week and a weekday labels the day, it ends nothing
12
+ if Weekday === to && Rel === from && Unit === from.node && from.node.unit == :week && @t[fe] == L::DASH
13
+ return Within.new(to, from)
14
+ end
15
+ # "9:30 - 11:00 CST": the zone is a trailing atom too
16
+ if Zoned === to && Clock === to.node
17
+ return Zoned.new(Range.new(from, to.node), to.offset, to.numeric) if Clock === from
18
+ # "15:00 GMT - 18:00 GMT": the same zone at both ends is one zone for the range
19
+ if Zoned === from && Clock === from.node && from.offset == to.offset
20
+ return Zoned.new(Range.new(from.node, to.node), to.offset, to.numeric)
21
+ end
22
+ end
23
+ if Date === from && from.month && from.day && !from.sep && (Clock === to || (Date === to && !to.month)) &&
24
+ (d = bare_day(ts, @i))
25
+ to = Date.new(from.year, from.month, d, nil, from.short)
26
+ elsif Date === to && to.month && to.day && !to.sep && (Clock === from || (Date === from && !from.month)) &&
27
+ (d = bare_day(fs, fe))
28
+ from = Date.new(to.year, to.month, d, nil, to.short)
29
+ elsif Date === to && to.sep == :dot && to.year && to.month > 12 && to.day <= 12 && Clock === from && (d = bare_day(fs, fe))
30
+ # D48: "12.-14.06.2021": a day before a dotted date that can only be day first is a day of its month
31
+ to = Date.new(to.year, to.day, to.month, nil, to.short)
32
+ from = Date.new(to.year, to.month, d, nil, to.short)
33
+ elsif Within === to && size(to.outer) == 5 && (d = bare_day(fs, fe) || (Date === from && !from.month && from.day)) &&
34
+ (e = Date === to.inner && !to.inner.month ? to.inner.day : Clock === to.inner && bare_day(ts, ts + S))
35
+ return Range.new(Within.new(Date.new(nil, nil, d, nil, false), to.outer),
36
+ Within.new(Date.new(nil, nil, e, nil, false), to.outer))
37
+ end
38
+ if Date === from && Date === to && from.month && to.month && !from.year && to.year && from.sep == to.sep
39
+ later = !from.sep && (from.month > to.month || (from.month == to.month && (from.day || 0) > (to.day || 0)))
40
+ from = Date.new(later ? to.year - 1 : to.year, from.month, from.day, from.sep, to.short)
41
+ end
42
+ # D48: "monday to friday last week" is within that week too; Sunday's place in it is the locale's (D19)
43
+ # D48: "friday to monday next week" starts in that week and ends on the Monday after it
44
+ if Within === to && ((Clock === from && Clock === to.inner) ||
45
+ (Weekday === from && Weekday === to.inner && from.wday >= 1))
46
+ return Within.new(Range.new(from, to.inner), to.outer)
47
+ end
48
+ return Within.new(Range.new(from.inner, to), from.outer) if Within === from && Clock === from.inner && Clock === to
49
+ # D51: "Friday, between 9-6 PT": the zone is the day's too
50
+ if Within === from && Clock === from.inner && Weekday === from.outer && Zoned === to && Clock === to.node
51
+ return Zoned.new(Within.new(Range.new(from.inner, to.node), from.outer), to.offset, to.numeric)
52
+ end
53
+ Range.new(from, to)
54
+ end
55
+
56
+ # D44: "later than 9:30 but before 11:00 on thursday" is the range between two clock times; the day an
57
+ # after starts on, or ends on, would differ from a range's
58
+ def between(dir, node)
59
+ r = Within === node ? node.inner : node
60
+ return failure(:conflict) unless (dir == :after || dir == :from) && Range === r && Clock === r.from && Clock === r.to
61
+ node
62
+ end
63
+
64
+ # D44: "2012 or later", "3 pm onwards": from X on; "2012 or earlier": until X's end, with no start. "or
65
+ # after" and "or before" only end the phrase: "before 2010 or after 2018" is two. D48: "and after" too
66
+ def open_after(node)
67
+ return node if Failure === node || Unit === node
68
+ return failure(:conflict) if @but # "9 but before 11" says after nothing
69
+ return node unless @t[@i] == L::WORD
70
+ w = @t[@i + 1]
71
+ return clipped(node, w) if w == :before || w == :after
72
+ if (w == :or || w == :and) && @i + 2 * S == @n && @t[@i + S] == L::WORD && ((x = @t[@i + S + 1]) == :after || x == :before)
73
+ @i += S
74
+ w = x == :after ? :orlater : :orearlier
75
+ end
76
+ return node unless w == :orlater || w == :orearlier
77
+ @i += S
78
+ Open.new(w == :orlater ? :from : :through, node)
79
+ end
80
+
81
+ # D48: "tomorrow before 4pm", "tuesday of next week after 2pm": an open end after a day stays inside it
82
+ def clipped(node, w)
83
+ return failure(:conflict) if size(node) != 2 || Clock === node
84
+ @i += S
85
+ at = @i
86
+ c = item(true)
87
+ return c if Failure === c
88
+ return failure(:conflict, at) unless Clock === c
89
+ w == :before ? Range.new(node, c) : Within.new(Range.new(c, MIDNIGHT), node)
90
+ end
91
+
92
+ MIDNIGHT = Clock.new(0, 0, 0, nil, :midnight, false, false)
93
+
94
+ # D44: "june 12-14, 2021", "august 10 to 22 2012": a bare day and a year end a range begun with a month's
95
+ # day, and the year is both ends'
96
+ def day_and_year(from, ts)
97
+ return unless Date === from && from.month && from.day && !from.year && !from.sep
98
+ return unless @t[ts + S] == L::NUM && @t[ts + S + 2] & L::DIGITS == 4 && (d = bare_day(ts, ts + S))
99
+ year = @t[ts + S + 1]
100
+ @i = ts + 2 * S
101
+ Range.new(Date.new(year, from.month, from.day, nil, false), Date.new(year, from.month, d, nil, false))
102
+ end
103
+
104
+ # The day a range end written as one number or ordinal names ("15", "the 15th"), or nil
105
+ def bare_day(s, e)
106
+ s += S if @t[s + 1] == :the
107
+ return unless e == s + S && (@t[s] == L::NUM || @t[s] == L::ORD) && @t[s + 2] & (L::DIGITS | L::SHORT) <= 2
108
+ v = @t[s + 1]
109
+ v if v >= 1 && v <= 31
110
+ end
111
+ end
112
+ end