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,245 @@
|
|
|
1
|
+
require_relative "grammar"
|
|
2
|
+
require_relative "assumption"
|
|
3
|
+
require_relative "calendar"
|
|
4
|
+
|
|
5
|
+
module Acute
|
|
6
|
+
# Tree plus context to readings, the main one first. Every convention (the D-rules) lives under resolve/.
|
|
7
|
+
#
|
|
8
|
+
# Inside resolve, times are local wall seconds (Zone's "local"), so day and month arithmetic is plain
|
|
9
|
+
# Integer maths; the readings are converted to UTC once, at the end.
|
|
10
|
+
# @api private
|
|
11
|
+
module Resolve
|
|
12
|
+
G = Grammar
|
|
13
|
+
# begin and end are epochs, end exclusive, either nil when open; dst is the code of a DST adjustment made
|
|
14
|
+
# to begin, if any; point is the D1 point of a part of day; frac the fraction of a second an instant was written with (D47)
|
|
15
|
+
Reading = Struct.new(:begin, :end, :grain, :dst, :point, :frac)
|
|
16
|
+
# The same in local wall seconds. An instant ("now", "in 3 hours", a time with an offset) also carries its
|
|
17
|
+
# UTC in ub and ue, so a wall time the clocks repeat keeps the side it is on; dst is the DST adjustment
|
|
18
|
+
# made converting ub from a zone named in the text (D47)
|
|
19
|
+
Span = Struct.new(:b, :e, :grain, :point, :ub, :ue, :dst)
|
|
20
|
+
|
|
21
|
+
APPROXIMATE = Assumption.for(:approximate)
|
|
22
|
+
FIXED_OFFSET = Assumption.for(:fixed_offset)
|
|
23
|
+
AMBIGUOUS_ZONE = Assumption.for(:ambiguous_zone)
|
|
24
|
+
WEEKDAY_MISMATCH = Assumption.for(:weekday_mismatch)
|
|
25
|
+
|
|
26
|
+
# An empty Array when nothing fits ("feb 30")
|
|
27
|
+
# A reading the resolver gave up on (Period::LIMIT) is a Failure with failure: true, otherwise no readings
|
|
28
|
+
def self.call(node, ctx, failure: false)
|
|
29
|
+
list = spans(node, ctx)
|
|
30
|
+
if list.empty? && !(date = Periodic.unfiltered(node)).equal?(node)
|
|
31
|
+
list = mismatch(node, date, ctx)
|
|
32
|
+
end
|
|
33
|
+
readings(list, ctx)
|
|
34
|
+
rescue Period::Limit
|
|
35
|
+
# A rescue costs nothing until something raises, where a catch costs every parse
|
|
36
|
+
failure ? Failure.new(:business_day_limit, 0, 0) : []
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# D37, D43: a weekday that fits none of a date's coming readings may fit the one just gone ("Thu Aug 10"
|
|
40
|
+
# said on 16 Aug 2006); failing that, the date wins ("Sat, Sep 21" when that is a Tuesday). No other
|
|
41
|
+
# year is searched for.
|
|
42
|
+
def self.mismatch(node, date, ctx)
|
|
43
|
+
unless ctx.past?
|
|
44
|
+
ctx.past = :back
|
|
45
|
+
list = spans(node, ctx)
|
|
46
|
+
ctx.past = false
|
|
47
|
+
return list unless list.empty?
|
|
48
|
+
end
|
|
49
|
+
list = spans(date, ctx)
|
|
50
|
+
# D37: a weekday that fits one of the date's readings picks it ("Sat 16th" said on Wednesday the 16th)
|
|
51
|
+
wday = node
|
|
52
|
+
wday = wday.outer until G::Weekday === wday.inner
|
|
53
|
+
fit = list.select { |s| (s.b.div(Period::DAY) + 4) % 7 == wday.inner.wday }
|
|
54
|
+
unless fit.empty?
|
|
55
|
+
ctx.unnote(Periodic::SAME_DAY) || ctx.unnote(Periodic::SAME_DAY_PASSED) if fit.size < list.size
|
|
56
|
+
return fit
|
|
57
|
+
end
|
|
58
|
+
ctx.note(WEEKDAY_MISMATCH) unless list.empty?
|
|
59
|
+
list
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.spans(node, ctx)
|
|
63
|
+
case G::KIND[node.class]
|
|
64
|
+
when :clock then Clock.bare(node, ctx)
|
|
65
|
+
when :day_ref then Day.whole(node, ctx)
|
|
66
|
+
when :now
|
|
67
|
+
t = ctx.now_local
|
|
68
|
+
[Span.new(t, t + 1, :second, nil, ctx.now, ctx.now + 1)]
|
|
69
|
+
when :date then Dates.spans(node, ctx)
|
|
70
|
+
when :within then within(node, ctx)
|
|
71
|
+
when :rel then Rel.spans(node, ctx)
|
|
72
|
+
when :shift then Shift.spans(node, ctx)
|
|
73
|
+
when :counted then Shift.counted(node, ctx)
|
|
74
|
+
when :closest then Shift.closest(node, ctx)
|
|
75
|
+
when :range then Ranges.spans(node, ctx)
|
|
76
|
+
when :open then Ranges.open(node, ctx)
|
|
77
|
+
when :lasting then Ranges.lasting(node, ctx)
|
|
78
|
+
when :holding then Resolve.spans(node.node, ctx).map { |s| Period.unit(node.unit, ctx, s.b) } # D19
|
|
79
|
+
when :fuzzy then Fuzzy.spans(node, ctx)
|
|
80
|
+
when :fiscal then Period.fiscal(node, ctx)
|
|
81
|
+
when :years then Dates.years(node, ctx)
|
|
82
|
+
when :approx
|
|
83
|
+
ctx.note(APPROXIMATE) # D14: the exact time, flagged
|
|
84
|
+
spans(node.node, ctx)
|
|
85
|
+
when :zoned then zoned(node, ctx)
|
|
86
|
+
else Periodic.bare(node, ctx)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# "friday 3pm" repeats weekly, so it is found like a weekday; "tomorrow 3pm" is 3pm inside each reading of
|
|
91
|
+
# tomorrow
|
|
92
|
+
def self.within(node, ctx)
|
|
93
|
+
return today_in(node.outer.year, ctx) if G::Date === node.outer && node.outer.year && !node.outer.month && today?(node.inner)
|
|
94
|
+
if (cyc = Periodic.cycle(node.outer))
|
|
95
|
+
return Periodic.bare(node, ctx, cyc)
|
|
96
|
+
end
|
|
97
|
+
if G::Shift === node.outer && !node.outer.anchor && (r = shifted(node, ctx))
|
|
98
|
+
return r
|
|
99
|
+
end
|
|
100
|
+
outers = spans(node.outer, ctx)
|
|
101
|
+
if outers.size == 1
|
|
102
|
+
out = Periodic.inside(node.inner, node.outer, outers[0], ctx)
|
|
103
|
+
return out unless this_passed?(node, out, ctx)
|
|
104
|
+
# D21: "this saturday at noon" said after noon on Saturday has passed, so it is found as "this (saturday at noon)"
|
|
105
|
+
return Rel.periodic(:this, G::Within.new(node.inner, node.outer.node), ctx)
|
|
106
|
+
end
|
|
107
|
+
out = []
|
|
108
|
+
outers.each do |s|
|
|
109
|
+
Periodic.inside(node.inner, node.outer, s, ctx).each { |x| out << x unless out.any? { |o| o.b == x.b && o.e == x.e } }
|
|
110
|
+
end
|
|
111
|
+
# D37, D44: a weekday that fits both orders of a numeric date picks the locale's ("Friday 7/6")
|
|
112
|
+
if out.size > 1 && G::Weekday === node.inner && G::Date === node.outer && node.outer.sep
|
|
113
|
+
ctx.unnote(Dates::ORDER)
|
|
114
|
+
return out.take(1)
|
|
115
|
+
end
|
|
116
|
+
out
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# D44: a recurring day beside a shift from now is the one in its own cycle that holds the shifted instant:
|
|
120
|
+
# "thanksgiving in 9 months", "3 months ago saturday"; an ordinal's is the shift's unit when that is
|
|
121
|
+
# coarser: "first monday in 2 years"
|
|
122
|
+
def self.shifted(node, ctx)
|
|
123
|
+
cyc = Periodic.cycle(node.inner)
|
|
124
|
+
return if !cyc || cyc == :day
|
|
125
|
+
unit = node.outer.unit
|
|
126
|
+
unit = cyc unless ordinal?(node.inner) && G::UNIT_SIZE[unit] > G::UNIT_SIZE[cyc]
|
|
127
|
+
t = Shift.spans(node.outer, ctx)[0].b
|
|
128
|
+
Periodic.inside(node.inner, nil, Period.span(unit, ctx, Period.index(unit, ctx, t)), ctx)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def self.this_passed?(node, out, ctx)
|
|
132
|
+
o = node.outer
|
|
133
|
+
G::Rel === o && o.word == :this && G::Clock === node.inner && !ctx.past && !out.empty? &&
|
|
134
|
+
out[0].e <= ctx.now_local && Periodic.cycle(o.node) && Periodic.cycle(o.node) != :day
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def self.today?(node)
|
|
138
|
+
(G::DayRef === node && node.offset == 0) || (G::Rel === node && node.word == :this && G::Unit === node.node && node.node.unit == :day)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# D48: "this day 1800" is today's date in that year
|
|
142
|
+
def self.today_in(year, ctx)
|
|
143
|
+
md = Calendar.civil_from_days(ctx.today) % 10_000
|
|
144
|
+
m = md / 100
|
|
145
|
+
d = md % 100
|
|
146
|
+
return [] if d > Calendar.days_in_month(year, m)
|
|
147
|
+
[Day.day(Calendar.days_from_civil(year, m, d))]
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def self.ordinal?(node)
|
|
151
|
+
case node
|
|
152
|
+
when G::Nth then true
|
|
153
|
+
when G::Within then ordinal?(node.outer)
|
|
154
|
+
when G::Fuzzy then ordinal?(node.node)
|
|
155
|
+
else false
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# D18: an offset in the text wins over the zone, and fixes the instant; D29: a numeric one makes the time
|
|
160
|
+
# machine-written
|
|
161
|
+
def self.zoned(node, ctx)
|
|
162
|
+
off = node.offset
|
|
163
|
+
return named_zone(node, Zone.for(off.name), ctx) if Symbol === off
|
|
164
|
+
return ambiguous_zone(node, off, ctx) unless Integer === off
|
|
165
|
+
ctx.note(FIXED_OFFSET)
|
|
166
|
+
ctx.machine = node.numeric
|
|
167
|
+
inner = ctx.in_zone(Zone.fixed(off)) { spans(node.node, ctx) }
|
|
168
|
+
ctx.machine = false
|
|
169
|
+
zone = ctx.zone
|
|
170
|
+
inner.map do |s|
|
|
171
|
+
ub = s.b && s.b - off
|
|
172
|
+
ue = s.e && s.e - off
|
|
173
|
+
if ub && ue && !s.point && (home = zone.steady_offset(ub, ue))
|
|
174
|
+
Span.new(ub + home, ue + home, s.grain, nil, ub, ue)
|
|
175
|
+
else
|
|
176
|
+
Span.new(ub && ctx.local(ub), ue && ctx.local(ue), s.grain, s.point && ctx.local(s.point - off), ub, ue)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# D44: under an IANA name in the text the wall times are that zone's, with its own clock changes
|
|
182
|
+
def self.named_zone(node, zone, ctx)
|
|
183
|
+
ctx.note(FIXED_OFFSET)
|
|
184
|
+
inner = ctx.in_zone(zone) { spans(node.node, ctx) }
|
|
185
|
+
inner.map do |s|
|
|
186
|
+
ctx.dst = nil
|
|
187
|
+
ub = s.ub || (s.b && zone.local_to_utc(s.b, ctx, ctx.dst_policy))
|
|
188
|
+
dst = s.ub ? s.dst : ctx.dst
|
|
189
|
+
ue = s.ue || (s.e && zone.local_to_utc(s.e, ctx, ctx.dst_policy))
|
|
190
|
+
point = s.point && zone.local_to_utc(s.point, ctx, ctx.dst_policy)
|
|
191
|
+
Span.new(ub && ctx.local(ub), ue && ctx.local(ue), s.grain, point && ctx.local(point), ub, ue, dst)
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# D38: each of an ambiguous abbreviation's offsets in order; in the country it leads with, the first alone
|
|
196
|
+
def self.ambiguous_zone(node, offs, ctx)
|
|
197
|
+
home = offs[0]
|
|
198
|
+
offs = offs.drop(1) if String === home
|
|
199
|
+
return zoned(G::Zoned.new(node.node, offs[0], false), ctx) if home == ctx.locale.country
|
|
200
|
+
ctx.note(AMBIGUOUS_ZONE)
|
|
201
|
+
offs.flat_map { zoned(G::Zoned.new(node.node, _1, false), ctx) }
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def self.readings(spans, ctx)
|
|
205
|
+
zone = ctx.zone
|
|
206
|
+
frac = ctx.frac
|
|
207
|
+
out = []
|
|
208
|
+
spans.each do |s|
|
|
209
|
+
b = s.b
|
|
210
|
+
e = s.e
|
|
211
|
+
point = s.point
|
|
212
|
+
if !s.ub && !s.ue && b && e && (!point || (point >= b && point <= e)) && (off = zone.steady_offset(b, e))
|
|
213
|
+
b -= off
|
|
214
|
+
e -= off
|
|
215
|
+
point -= off if point
|
|
216
|
+
dst = nil
|
|
217
|
+
else
|
|
218
|
+
ctx.dst = nil
|
|
219
|
+
b = s.ub || (b && zone.local_to_utc(b, ctx, ctx.dst_policy))
|
|
220
|
+
dst = s.ub ? s.dst : ctx.dst
|
|
221
|
+
e = s.ue || (e && zone.local_to_utc(e, ctx, ctx.dst_policy))
|
|
222
|
+
# a start the clocks skipped moved forward by the gap, past the end: it keeps its length
|
|
223
|
+
e = b + (s.e - s.b) if b && e && e <= b
|
|
224
|
+
point &&= zone.local_to_utc(point, ctx, ctx.dst_policy)
|
|
225
|
+
end
|
|
226
|
+
next if !out.empty? && out.any? { |o| o.begin == b && o.end == e }
|
|
227
|
+
out << Reading.new(b, e, s.grain, dst, point, frac && e && b && e - b == 1 ? frac : nil)
|
|
228
|
+
end
|
|
229
|
+
out
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
require_relative "resolve/period"
|
|
235
|
+
require_relative "resolve/clock"
|
|
236
|
+
require_relative "resolve/day"
|
|
237
|
+
require_relative "resolve/dates"
|
|
238
|
+
require_relative "resolve/named"
|
|
239
|
+
require_relative "resolve/periodic"
|
|
240
|
+
require_relative "resolve/nth"
|
|
241
|
+
require_relative "resolve/rel"
|
|
242
|
+
require_relative "resolve/shift"
|
|
243
|
+
require_relative "resolve/ranges"
|
|
244
|
+
require_relative "resolve/fuzzy"
|
|
245
|
+
require_relative "resolve/recur"
|
data/lib/acute/result.rb
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
require "date"
|
|
2
|
+
require "json"
|
|
3
|
+
require_relative "calendar"
|
|
4
|
+
|
|
5
|
+
module Acute
|
|
6
|
+
# A parsed time or span. Frozen; epochs are kept as Integers and each Time is built when asked for, so a parse
|
|
7
|
+
# that only wants one field pays for no others.
|
|
8
|
+
class Result
|
|
9
|
+
# @api private
|
|
10
|
+
DAY_GRAINS = %i[day weekend week month quarter season year decade century].freeze
|
|
11
|
+
# @api private
|
|
12
|
+
MONTHS = Ractor.make_shareable(%w[Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec])
|
|
13
|
+
WEEKDAYS = Ractor.make_shareable(%w[Sun Mon Tue Wed Thu Fri Sat])
|
|
14
|
+
NO_ASSUMPTIONS = [].freeze
|
|
15
|
+
GRAIN_SECONDS = { second: 1, minute: 60, hour: 3600 }.freeze
|
|
16
|
+
private_constant :DAY_GRAINS, :NO_ASSUMPTIONS, :GRAIN_SECONDS
|
|
17
|
+
|
|
18
|
+
attr_reader :text, :grain, :assumptions
|
|
19
|
+
|
|
20
|
+
# @api private: results come from the calls. readings: the main reading first; the rest become alternatives.
|
|
21
|
+
# matched: the part of the text used (A12)
|
|
22
|
+
def initialize(text, readings, zone, assumptions, matched = nil)
|
|
23
|
+
main = readings[0]
|
|
24
|
+
@text = text
|
|
25
|
+
@matched = matched
|
|
26
|
+
@readings = readings.freeze
|
|
27
|
+
@point = main.point || main.begin || main.end
|
|
28
|
+
@begin_epoch = main.begin
|
|
29
|
+
@end_epoch = main.end
|
|
30
|
+
@grain = main.grain
|
|
31
|
+
@frac = main.frac
|
|
32
|
+
@zone = zone
|
|
33
|
+
@assumptions = assumptions
|
|
34
|
+
freeze
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def kind = :time
|
|
38
|
+
|
|
39
|
+
def matched = @matched || @text
|
|
40
|
+
|
|
41
|
+
def time = at(@point, @frac)
|
|
42
|
+
|
|
43
|
+
def begin = @begin_epoch && at(@begin_epoch, @frac)
|
|
44
|
+
|
|
45
|
+
def end = @end_epoch && at(@end_epoch, @frac)
|
|
46
|
+
|
|
47
|
+
def range = self.begin...self.end
|
|
48
|
+
|
|
49
|
+
def zone = @zone.name
|
|
50
|
+
|
|
51
|
+
# The local date of the start, or of the time when the start is open, at any grain
|
|
52
|
+
def date
|
|
53
|
+
ymd = Calendar.civil_from_days(local(@begin_epoch || @point).div(86_400))
|
|
54
|
+
Date.new(ymd / 10_000, ymd / 100 % 100, ymd % 100)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def alternatives
|
|
58
|
+
@readings.drop(1).map! { |r| Result.new(@text, [r], @zone, NO_ASSUMPTIONS, @matched) }.freeze
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def ambiguous? = @readings.size > 1
|
|
62
|
+
|
|
63
|
+
def instant? = !span?
|
|
64
|
+
|
|
65
|
+
# A day or longer, a part of the day, a clock range, or open at one end
|
|
66
|
+
def span? = DAY_GRAINS.include?(@grain) || open? || @end_epoch - @begin_epoch > GRAIN_SECONDS.fetch(@grain, 1)
|
|
67
|
+
|
|
68
|
+
def open? = @begin_epoch.nil? || @end_epoch.nil?
|
|
69
|
+
|
|
70
|
+
def iso8601 = span? ? "#{iso(self.begin) || ".."}/#{iso(self.end) || ".."}" : iso(time)
|
|
71
|
+
|
|
72
|
+
def to_h
|
|
73
|
+
{
|
|
74
|
+
kind: "time", text: @text, time: iso(time), begin: iso(self.begin), end: iso(self.end),
|
|
75
|
+
grain: @grain.to_s, zone: zone,
|
|
76
|
+
assumptions: @assumptions.map { |a| { code: a.code.to_s, rule: a.rule, message: a.message } },
|
|
77
|
+
alternatives: alternatives.map { |r| { time: iso(r.time), begin: iso(r.begin), end: iso(r.end), grain: r.grain.to_s } }
|
|
78
|
+
}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def to_json(*args) = to_h.to_json(*args)
|
|
82
|
+
|
|
83
|
+
# String keys, as JSON.parse would give
|
|
84
|
+
def as_json(*) = JSON.parse(to_json)
|
|
85
|
+
|
|
86
|
+
# en-US: "Sun, Oct 18, 2026, 9am EDT", or "Sun, Oct 18, 2026, all day"
|
|
87
|
+
def to_s
|
|
88
|
+
local = local(@point)
|
|
89
|
+
days = local.div(86_400)
|
|
90
|
+
ymd = Calendar.civil_from_days(days)
|
|
91
|
+
date = "#{WEEKDAYS[Calendar.weekday(days)]}, #{MONTHS[ymd / 100 % 100 - 1]} #{ymd % 100}, #{ymd / 10_000}"
|
|
92
|
+
return "#{date}, all day" if DAY_GRAINS.include?(@grain)
|
|
93
|
+
|
|
94
|
+
secs = local - days * 86_400
|
|
95
|
+
hour = secs / 3600
|
|
96
|
+
clock = (hour % 12 == 0 ? 12 : hour % 12).to_s
|
|
97
|
+
seconds = @grain == :second && (secs % 60 != 0 || @frac)
|
|
98
|
+
clock << format(":%02d", secs / 60 % 60) if secs % 3600 != 0 || seconds
|
|
99
|
+
clock << format(":%02d", secs % 60) if seconds
|
|
100
|
+
clock << decimals(@frac) if @frac
|
|
101
|
+
"#{date}, #{clock}#{hour < 12 ? "am" : "pm"} #{@zone.abbreviation_at(@point)}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def inspect = "#<Acute::Result #{self}>"
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def local(epoch) = epoch + @zone.offset_at(epoch)
|
|
109
|
+
|
|
110
|
+
def at(epoch, frac = nil)
|
|
111
|
+
t = Time.at(epoch, in: @zone.offset_at(epoch))
|
|
112
|
+
frac ? t + frac : t
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def iso(t)
|
|
116
|
+
return unless t
|
|
117
|
+
return t.strftime("%FT%T%:z") if t.subsec == 0
|
|
118
|
+
"#{t.strftime("%FT%T")}#{decimals(t.subsec)}#{t.strftime("%:z")}"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# ".345": the fewest digits that give a fraction of a second exactly
|
|
122
|
+
def decimals(frac)
|
|
123
|
+
n = 1
|
|
124
|
+
n += 1 until (frac * 10**n).denominator == 1
|
|
125
|
+
format(".%0*d", n, frac * 10**n)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
module Acute
|
|
2
|
+
class Rule
|
|
3
|
+
# Standard 5-field cron, both ways (R20–R26): minute hour day-of-month month day-of-week, Sunday 0
|
|
4
|
+
module Cron
|
|
5
|
+
DAY = 86_400
|
|
6
|
+
MONTHS = Ractor.make_shareable(%w[jan feb mar apr may jun jul aug sep oct nov dec])
|
|
7
|
+
WEEKDAYS = Ractor.make_shareable(%w[sun mon tue wed thu fri sat])
|
|
8
|
+
# name, first value, last value, the names for them, where a name's index counts from
|
|
9
|
+
FIELDS = Ractor.make_shareable([["minute", 0, 59], ["hour", 0, 23], ["day of the month", 1, 31], ["month", 1, 12, MONTHS, 1],
|
|
10
|
+
["day of the week", 0, 7, WEEKDAYS, 0]])
|
|
11
|
+
MACROS = Ractor.make_shareable({ "@yearly" => "0 0 1 1 *", "@annually" => "0 0 1 1 *", "@monthly" => "0 0 1 * *",
|
|
12
|
+
"@weekly" => "0 0 * * 0", "@daily" => "0 0 * * *", "@midnight" => "0 0 * * *",
|
|
13
|
+
"@hourly" => "0 * * * *" })
|
|
14
|
+
# Anchored, with every repeat bounded and nothing nested, so a match can't backtrack
|
|
15
|
+
ITEM = %r{\A(?:(\*)|(\w{1,3})(?:-(\w{1,3}))?)(?:/(\d{1,3}))?\z}
|
|
16
|
+
# Quartz's last (L, 5L), nearest weekday (15W, LW), nth weekday (1#2) and no value (?)
|
|
17
|
+
NONSTANDARD = /[#?]|\A(?:\d{0,2}l|\d{1,2}w|lw)\z/
|
|
18
|
+
# first, last, whether steps are written: weekdays are listed, so the weekend is 0,6 and not */6
|
|
19
|
+
WRITE = Ractor.make_shareable([[0, 59, true], [0, 23, true], [1, 31, true], [1, 12, true], [0, 6, false]])
|
|
20
|
+
FINE = { minutely: 60, hourly: 3600 }.freeze
|
|
21
|
+
|
|
22
|
+
# A Rule, or [code, message, character range] (R25)
|
|
23
|
+
def self.read(line, now, zone, dst)
|
|
24
|
+
raise ArgumentError, "a cron line must be a String, got #{line.class}" unless String === line
|
|
25
|
+
ctx = Context.new(now, zone, :future, nil, nil, nil, nil, true, dst) # R18: occurrences follow dst:
|
|
26
|
+
return [:too_long, "longer than #{MAX_LENGTH} bytes", 0...0] if line.bytesize > MAX_LENGTH
|
|
27
|
+
return [:invalid_cron, "a cron line is plain ASCII", 0...0] unless line.valid_encoding? && line.ascii_only?
|
|
28
|
+
text = line.strip.downcase
|
|
29
|
+
lead = line.length - line.lstrip.length
|
|
30
|
+
whole = lead...(lead + text.length)
|
|
31
|
+
if text.start_with?("@")
|
|
32
|
+
return [:unsupported_cron, "@reboot runs at boot, not at a time", whole] if text == "@reboot"
|
|
33
|
+
text = MACROS[text] or return [:invalid_cron, "#{line.strip.inspect} is not a cron macro", whole]
|
|
34
|
+
lead = nil
|
|
35
|
+
end
|
|
36
|
+
fields = []
|
|
37
|
+
text.scan(/\S+/) { fields << [Regexp.last_match[0], Regexp.last_match.begin(0)] }
|
|
38
|
+
return [:invalid_cron, "a cron line has 5 fields, this has #{fields.size}", whole] unless fields.size == 5
|
|
39
|
+
sets = fields.each_with_index.map do |(f, at), i|
|
|
40
|
+
set = field(f, *FIELDS[i])
|
|
41
|
+
return [*set, lead ? (lead + at)...(lead + at + f.length) : whole] if Symbol === set[0]
|
|
42
|
+
set
|
|
43
|
+
end
|
|
44
|
+
sunday = sets[4].pop # R25: 7 is Sunday too
|
|
45
|
+
sets[4][0] ||= sunday
|
|
46
|
+
out = build(sets, fields[2][0].start_with?("*"), fields[4][0].start_with?("*"), ctx, line)
|
|
47
|
+
Rule === out ? out : [*out, whole]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The field's values as an Array of booleans indexed by value, or [code, message]
|
|
51
|
+
def self.field(text, name, low, high, names = nil, base = 0)
|
|
52
|
+
set = Array.new(high + 1, false)
|
|
53
|
+
text.split(",", -1).each do |item|
|
|
54
|
+
return [:unsupported_cron, "#{item.upcase.inspect} is not standard cron: no L, W, # or ?"] if item.match?(NONSTANDARD)
|
|
55
|
+
m = ITEM.match(item) or return [:invalid_cron, "#{item.inspect} is not a #{name}"]
|
|
56
|
+
step = m[4]&.to_i
|
|
57
|
+
return [:invalid_cron, "a step of #{m[4]} doesn't fit the #{name}"] if step && (step < 1 || step > high - low + 1)
|
|
58
|
+
if m[1]
|
|
59
|
+
a = low
|
|
60
|
+
b = high
|
|
61
|
+
else
|
|
62
|
+
a = value(m[2], low, high, names, base) or return [:invalid_cron, "#{m[2].inspect} is not a #{name} (#{low}-#{high})"]
|
|
63
|
+
b = m[3] ? value(m[3], low, high, names, base) : a
|
|
64
|
+
return [:invalid_cron, "#{m[3].inspect} is not a #{name} (#{low}-#{high})"] unless b
|
|
65
|
+
return [:invalid_cron, "#{item.inspect} runs backwards"] if b < a
|
|
66
|
+
return [:invalid_cron, "a step needs a range or *: #{item.inspect}"] if step && !m[3] # Vixie
|
|
67
|
+
end
|
|
68
|
+
a.step(b, step || 1) { set[_1] = true }
|
|
69
|
+
end
|
|
70
|
+
set
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def self.value(text, low, high, names, base)
|
|
74
|
+
n = if text.match?(/\A\d{1,2}\z/) then text.to_i
|
|
75
|
+
elsif names && (i = names.index(text)) then i + base
|
|
76
|
+
end
|
|
77
|
+
n if n && n >= low && n <= high
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# R25, R26: cron's fields as the shapes acute's own rules take
|
|
81
|
+
def self.build(sets, dom_star, dow_star, ctx, line)
|
|
82
|
+
minutes, hours, doms, months, dows = sets.map { |s| (0...s.size).select { s[_1] } }
|
|
83
|
+
all_doms = doms.size == 31
|
|
84
|
+
all_dows = dows.size == 7
|
|
85
|
+
# R26: with neither field starting with *, cron runs on a day either one matches (Vixie)
|
|
86
|
+
if !dom_star && !dow_star && !all_doms && !all_dows
|
|
87
|
+
return [:unsupported_cron, "cron runs when the day of the month or the day of the week matches; a rule can't say or"]
|
|
88
|
+
elsif !dom_star && !dow_star
|
|
89
|
+
all_doms = all_dows = true
|
|
90
|
+
elsif !all_doms && !all_dows
|
|
91
|
+
return [:unsupported_cron, "a rule can't hold a day of the month on given weekdays"]
|
|
92
|
+
end
|
|
93
|
+
all_months = months.size == 12
|
|
94
|
+
every_day = all_doms && all_dows && all_months
|
|
95
|
+
|
|
96
|
+
if every_day && hours.size == 24 && (n = grid(minutes, 0, 59)) && n < 60
|
|
97
|
+
return fine(:minutely, n, ctx, line)
|
|
98
|
+
elsif every_day && minutes == [0] && (n = grid(hours, 0, 23)) && n < 24
|
|
99
|
+
return fine(:hourly, n, ctx, line)
|
|
100
|
+
end
|
|
101
|
+
secs = hours.flat_map { |h| minutes.map { h * 3600 + _1 * 60 } } # R25: each hour with each minute (R4)
|
|
102
|
+
step = grid(months, 1, 12, offset: true)
|
|
103
|
+
parts = if every_day then [:daily, 1, nil, [], []]
|
|
104
|
+
elsif all_doms && all_months then [:weekly, 1, nil, [], dows]
|
|
105
|
+
elsif all_dows && step && step < 12 then [:monthly, step, nil, doms, []] # R23: every n months
|
|
106
|
+
elsif all_dows && months.size == 1 && doms.size == 1 then [:yearly, 1, months[0], doms, []]
|
|
107
|
+
end
|
|
108
|
+
return [:unsupported_cron, "a rule can't hold those days and months together"] unless parts
|
|
109
|
+
rule = Parts.new(*parts, [], nil, secs)
|
|
110
|
+
start = first(rule, rule.interval > 1 && months, ctx) or return [:invalid_cron, "the line never runs"]
|
|
111
|
+
Rule.new(rule, ctx.zone.name, start, true, nil, nil, [], false, ctx.dst_policy, now: ctx.now, text: line)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# The step n when values are every nth through high, from low or (offset) from less than n past it, and n
|
|
115
|
+
# divides the field evenly so the step doesn't restart short (R23); else nil
|
|
116
|
+
def self.grid(values, low, high, offset: false)
|
|
117
|
+
size = high - low + 1
|
|
118
|
+
n = values.size > 1 ? values[1] - values[0] : size
|
|
119
|
+
n if size % n == 0 && (offset ? values[0] - low < n : values[0] == low) && values == values[0].step(high, n).to_a
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# R23: a step finer than a day starts on cron's grid, counted from the top of the hour
|
|
123
|
+
def self.fine(freq, n, ctx, line)
|
|
124
|
+
step = n * FINE[freq]
|
|
125
|
+
local = ctx.local(ctx.now)
|
|
126
|
+
start = (local + step - 1).div(step) * step
|
|
127
|
+
Rule.new(Parts.new(freq, n, nil, [], [], [], nil, []), ctx.zone.name, start, true, nil, nil, [], false,
|
|
128
|
+
ctx.dst_policy, now: ctx.now, text: line)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# R2: the first run on or after now, as local wall seconds; with a step of months, only in those months
|
|
132
|
+
def self.first(rule, months, ctx)
|
|
133
|
+
today = ctx.local(ctx.now).div(DAY)
|
|
134
|
+
(today...(today + Resolve::Recur::SEARCH)).each do |day|
|
|
135
|
+
next unless rule.on?(day) && (!months || months.include?(Calendar.civil_from_days(day) / 100 % 100))
|
|
136
|
+
rule.secs.each { |s| return day * DAY + s if ctx.zone.local_to_utc(day * DAY + s, ctx) >= ctx.now }
|
|
137
|
+
end
|
|
138
|
+
nil
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# R20: the shortest way to write a field: *, */n, a-high/n, then runs and single values
|
|
142
|
+
def self.write(values, low, high, steps)
|
|
143
|
+
return "*" if values.size == high - low + 1
|
|
144
|
+
if steps && values.size > 1
|
|
145
|
+
n = values[1] - values[0]
|
|
146
|
+
if n > 1 && values[0] - low < n && values == values[0].step(high, n).to_a
|
|
147
|
+
return "*/#{n}" if values[0] == low
|
|
148
|
+
return "#{values[0]}-#{high}/#{n}" if values.size > 2
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
values.slice_when { _2 != _1 + 1 }.map { |run| run.size > 2 ? "#{run[0]}-#{run[-1]}" : run.join(",") }.join(",")
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def self.from_cron(line, now: nil, zone: nil, dst: nil)
|
|
156
|
+
out = Cron.read(line, now, zone, dst)
|
|
157
|
+
Rule === out ? out : nil
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def self.from_cron!(line, now: nil, zone: nil, dst: nil)
|
|
161
|
+
out = Cron.read(line, now, zone, dst)
|
|
162
|
+
raise ParseError.new(out[1], code: out[0], position: out[2]) unless Rule === out
|
|
163
|
+
out
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def cron = cron_or_reason[0]
|
|
167
|
+
|
|
168
|
+
def cron_reason = cron_or_reason[1]
|
|
169
|
+
|
|
170
|
+
private
|
|
171
|
+
|
|
172
|
+
def cant(what) = [nil, "cron can't express #{what.inspect}"]
|
|
173
|
+
|
|
174
|
+
# [line, nil] or [nil, reason]: R20–R24
|
|
175
|
+
def cron_or_reason
|
|
176
|
+
p = @parts
|
|
177
|
+
return cant("until") if @until # R21
|
|
178
|
+
return cant("#{@count} times") if @count
|
|
179
|
+
return cant("starting") if @started
|
|
180
|
+
return cant("except") unless @exdates.empty?
|
|
181
|
+
return cant("the #{list(p.nths.sort_by(&:first).map { ordinal(_1[0]) })} #{DAY_NAMES[p.nths[0][1]]}") unless p.nths.empty? # R24
|
|
182
|
+
return cant("the #{list(p.setpos.map { ordinal(_1) })} weekday") if p.setpos
|
|
183
|
+
return cant("the last day") if p.monthdays.any?(&:negative?)
|
|
184
|
+
return cant(every("second")) if p.freq == :secondly
|
|
185
|
+
return [nil, "cron has no seconds"] if p.secs.any? { _1 % 60 != 0 }
|
|
186
|
+
|
|
187
|
+
n = p.interval
|
|
188
|
+
fine = Cron::FINE[p.freq]
|
|
189
|
+
unit = UNITS[p.freq]
|
|
190
|
+
if fine && p.secs.empty? # R23
|
|
191
|
+
step = n * fine
|
|
192
|
+
return cant(every(unit)) unless 3600 % step == 0 || (step % 3600 == 0 && DAY % step == 0)
|
|
193
|
+
minute = step < 3600 ? (0...60).step(step / 60).to_a : [0]
|
|
194
|
+
hours = step < 3600 ? (0...24).to_a : (0...24).step(step / 3600).to_a
|
|
195
|
+
return [cron_line([minute, hours, (1..31).to_a, (1..12).to_a, (0..6).to_a]), nil]
|
|
196
|
+
end
|
|
197
|
+
return cant(every(p.days.empty? ? unit : nil).delete_suffix(" on")) if n > 1 && !(p.freq == :monthly && 12 % n == 0) # R23
|
|
198
|
+
minute = p.secs.empty? ? [0] : p.secs.map { _1 / 60 % 60 }.uniq.sort # R22
|
|
199
|
+
hours = p.secs.empty? ? [0] : p.secs.map { _1 / 3600 }
|
|
200
|
+
|
|
201
|
+
day = @timed ? @start.div(DAY) : @start
|
|
202
|
+
ymd = Calendar.civil_from_days(day)
|
|
203
|
+
doms = (1..31).to_a
|
|
204
|
+
months = (1..12).to_a
|
|
205
|
+
dows = (0..6).to_a
|
|
206
|
+
case p.freq
|
|
207
|
+
when :weekly then dows = p.days.empty? ? [Calendar.weekday(day)] : p.days.sort
|
|
208
|
+
when :monthly
|
|
209
|
+
doms = p.monthdays.empty? ? [ymd % 100] : p.monthdays.sort
|
|
210
|
+
months = months.select { (_1 - ymd / 100 % 100) % n == 0 }
|
|
211
|
+
when :yearly
|
|
212
|
+
doms = p.month ? p.monthdays : [ymd % 100]
|
|
213
|
+
months = [p.month || ymd / 100 % 100]
|
|
214
|
+
end
|
|
215
|
+
[cron_line([minute, hours, doms, months, dows]), nil]
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def cron_line(sets) = sets.zip(Cron::WRITE).map { |v, w| Cron.write(v.uniq, *w) }.join(" ")
|
|
219
|
+
|
|
220
|
+
private_constant :Cron
|
|
221
|
+
end
|
|
222
|
+
end
|