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,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
|