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.
- checksums.yaml +7 -0
- data/LICENSE +17 -0
- data/data/assumptions.yml +124 -0
- data/data/words.yml +699 -0
- data/exe/acute +4 -0
- data/lib/acute/assumption.rb +13 -0
- data/lib/acute/calendar.rb +91 -0
- data/lib/acute/chronic.rb +176 -0
- data/lib/acute/chronic_duration.rb +321 -0
- data/lib/acute/cli.rb +168 -0
- data/lib/acute/context.rb +205 -0
- data/lib/acute/describe.rb +316 -0
- data/lib/acute/duration.rb +120 -0
- data/lib/acute/errors.rb +47 -0
- data/lib/acute/explain.rb +165 -0
- data/lib/acute/extract.rb +493 -0
- data/lib/acute/grammar/clock_words.rb +109 -0
- data/lib/acute/grammar/compact.rb +222 -0
- data/lib/acute/grammar/duration.rb +93 -0
- data/lib/acute/grammar/nesting.rb +104 -0
- data/lib/acute/grammar/numbers.rb +378 -0
- data/lib/acute/grammar/ordinals.rb +101 -0
- data/lib/acute/grammar/ranges.rb +112 -0
- data/lib/acute/grammar/rule.rb +277 -0
- data/lib/acute/grammar/shifts.rb +226 -0
- data/lib/acute/grammar/words.rb +307 -0
- data/lib/acute/grammar.rb +378 -0
- data/lib/acute/holidays.rb +97 -0
- data/lib/acute/lexer.rb +487 -0
- data/lib/acute/locale.rb +53 -0
- data/lib/acute/mcp.rb +224 -0
- data/lib/acute/parser.rb +66 -0
- data/lib/acute/read.rb +21 -0
- data/lib/acute/resolve/clock.rb +75 -0
- data/lib/acute/resolve/dates.rb +75 -0
- data/lib/acute/resolve/day.rb +24 -0
- data/lib/acute/resolve/fuzzy.rb +91 -0
- data/lib/acute/resolve/named.rb +116 -0
- data/lib/acute/resolve/nth.rb +47 -0
- data/lib/acute/resolve/period.rb +160 -0
- data/lib/acute/resolve/periodic.rb +274 -0
- data/lib/acute/resolve/ranges.rb +165 -0
- data/lib/acute/resolve/recur.rb +187 -0
- data/lib/acute/resolve/rel.rb +146 -0
- data/lib/acute/resolve/shift.rb +81 -0
- data/lib/acute/resolve.rb +245 -0
- data/lib/acute/result.rb +128 -0
- data/lib/acute/rule/cron.rb +222 -0
- data/lib/acute/rule/expand.rb +401 -0
- data/lib/acute/rule.rb +232 -0
- data/lib/acute/tables/assumptions.rb +39 -0
- data/lib/acute/tables/words.rb +656 -0
- data/lib/acute/tables/zones.rb +930 -0
- data/lib/acute/tool/compact.rb +82 -0
- data/lib/acute/tool/ruby_llm.rb +29 -0
- data/lib/acute/tool.rb +192 -0
- data/lib/acute/verify.rb +216 -0
- data/lib/acute/version.rb +3 -0
- data/lib/acute/zone.rb +252 -0
- data/lib/acute.rb +160 -0
- data/schema/description.json +12 -0
- data/schema/duration.json +26 -0
- data/schema/extraction.json +30 -0
- data/schema/failure.json +29 -0
- data/schema/now.json +17 -0
- data/schema/result.json +45 -0
- data/schema/rule.json +19 -0
- data/schema/tools/describe.json +13 -0
- data/schema/tools/duration.json +12 -0
- data/schema/tools/extract.json +13 -0
- data/schema/tools/now.json +9 -0
- data/schema/tools/parse.json +13 -0
- data/schema/tools/recurrence.json +12 -0
- data/schema/tools/verify.json +14 -0
- data/schema/verdict.json +25 -0
- 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"
|