acute 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +17 -0
  3. data/data/assumptions.yml +124 -0
  4. data/data/words.yml +699 -0
  5. data/exe/acute +4 -0
  6. data/lib/acute/assumption.rb +13 -0
  7. data/lib/acute/calendar.rb +91 -0
  8. data/lib/acute/chronic.rb +176 -0
  9. data/lib/acute/chronic_duration.rb +321 -0
  10. data/lib/acute/cli.rb +168 -0
  11. data/lib/acute/context.rb +205 -0
  12. data/lib/acute/describe.rb +316 -0
  13. data/lib/acute/duration.rb +120 -0
  14. data/lib/acute/errors.rb +47 -0
  15. data/lib/acute/explain.rb +165 -0
  16. data/lib/acute/extract.rb +493 -0
  17. data/lib/acute/grammar/clock_words.rb +109 -0
  18. data/lib/acute/grammar/compact.rb +222 -0
  19. data/lib/acute/grammar/duration.rb +93 -0
  20. data/lib/acute/grammar/nesting.rb +104 -0
  21. data/lib/acute/grammar/numbers.rb +378 -0
  22. data/lib/acute/grammar/ordinals.rb +101 -0
  23. data/lib/acute/grammar/ranges.rb +112 -0
  24. data/lib/acute/grammar/rule.rb +277 -0
  25. data/lib/acute/grammar/shifts.rb +226 -0
  26. data/lib/acute/grammar/words.rb +307 -0
  27. data/lib/acute/grammar.rb +378 -0
  28. data/lib/acute/holidays.rb +97 -0
  29. data/lib/acute/lexer.rb +487 -0
  30. data/lib/acute/locale.rb +53 -0
  31. data/lib/acute/mcp.rb +224 -0
  32. data/lib/acute/parser.rb +66 -0
  33. data/lib/acute/read.rb +21 -0
  34. data/lib/acute/resolve/clock.rb +75 -0
  35. data/lib/acute/resolve/dates.rb +75 -0
  36. data/lib/acute/resolve/day.rb +24 -0
  37. data/lib/acute/resolve/fuzzy.rb +91 -0
  38. data/lib/acute/resolve/named.rb +116 -0
  39. data/lib/acute/resolve/nth.rb +47 -0
  40. data/lib/acute/resolve/period.rb +160 -0
  41. data/lib/acute/resolve/periodic.rb +274 -0
  42. data/lib/acute/resolve/ranges.rb +165 -0
  43. data/lib/acute/resolve/recur.rb +187 -0
  44. data/lib/acute/resolve/rel.rb +146 -0
  45. data/lib/acute/resolve/shift.rb +81 -0
  46. data/lib/acute/resolve.rb +245 -0
  47. data/lib/acute/result.rb +128 -0
  48. data/lib/acute/rule/cron.rb +222 -0
  49. data/lib/acute/rule/expand.rb +401 -0
  50. data/lib/acute/rule.rb +232 -0
  51. data/lib/acute/tables/assumptions.rb +39 -0
  52. data/lib/acute/tables/words.rb +656 -0
  53. data/lib/acute/tables/zones.rb +930 -0
  54. data/lib/acute/tool/compact.rb +82 -0
  55. data/lib/acute/tool/ruby_llm.rb +29 -0
  56. data/lib/acute/tool.rb +192 -0
  57. data/lib/acute/verify.rb +216 -0
  58. data/lib/acute/version.rb +3 -0
  59. data/lib/acute/zone.rb +252 -0
  60. data/lib/acute.rb +160 -0
  61. data/schema/description.json +12 -0
  62. data/schema/duration.json +26 -0
  63. data/schema/extraction.json +30 -0
  64. data/schema/failure.json +29 -0
  65. data/schema/now.json +17 -0
  66. data/schema/result.json +45 -0
  67. data/schema/rule.json +19 -0
  68. data/schema/tools/describe.json +13 -0
  69. data/schema/tools/duration.json +12 -0
  70. data/schema/tools/extract.json +13 -0
  71. data/schema/tools/now.json +9 -0
  72. data/schema/tools/parse.json +13 -0
  73. data/schema/tools/recurrence.json +12 -0
  74. data/schema/tools/verify.json +14 -0
  75. data/schema/verdict.json +25 -0
  76. metadata +117 -0
@@ -0,0 +1,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"
@@ -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