hron 1.0.0 → 2.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 +4 -4
- data/README.md +126 -21
- data/hron.gemspec +7 -11
- data/lib/hron/ast.rb +28 -54
- data/lib/hron/cron.rb +369 -652
- data/lib/hron/display.rb +6 -4
- data/lib/hron/error.rb +54 -17
- data/lib/hron/eval/calendar.rb +120 -0
- data/lib/hron/eval/wall_clock.rb +65 -0
- data/lib/hron/evaluator.rb +339 -1010
- data/lib/hron/lexer.rb +84 -94
- data/lib/hron/parser.rb +369 -502
- data/lib/hron/parts.rb +257 -0
- data/lib/hron/schedule.rb +81 -19
- data/lib/hron/version.rb +1 -1
- data/lib/hron.rb +13 -3
- metadata +14 -53
data/lib/hron/parser.rb
CHANGED
|
@@ -1,289 +1,251 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "date"
|
|
4
|
+
require "tzinfo"
|
|
4
5
|
require_relative "ast"
|
|
5
6
|
require_relative "error"
|
|
6
7
|
require_relative "lexer"
|
|
7
8
|
|
|
8
9
|
module Hron
|
|
9
|
-
# Parser for hron expressions
|
|
10
10
|
class Parser
|
|
11
|
+
# The `{what}` of each `expected {what}, got ...` error, one per phrase in the position table
|
|
12
|
+
# of spec/README.md, "Parse errors".
|
|
13
|
+
module Expected
|
|
14
|
+
EVERY_OR_ON = "'every' or 'on'"
|
|
15
|
+
REPEATER = "'day', 'weekday', 'weekend', a day name, 'week', 'month', 'year' or a number"
|
|
16
|
+
UNIT = "a unit ('min', 'hours', 'days', 'weeks', 'months' or 'years')"
|
|
17
|
+
AT = "'at'"
|
|
18
|
+
TIME = "a time (HH:MM)"
|
|
19
|
+
FROM = "'from'"
|
|
20
|
+
TO = "'to'"
|
|
21
|
+
DAY_TARGET = "'day', 'weekday', 'weekend' or a day name"
|
|
22
|
+
ON = "'on'"
|
|
23
|
+
DAY_NAME = "a day name"
|
|
24
|
+
THE = "'the'"
|
|
25
|
+
MONTH_TARGET = "a day such as 15th, 'last', an ordinal such as 'first', 'next', 'previous' or 'nearest'"
|
|
26
|
+
MONTH_LAST = "'day', 'weekday' or a day name"
|
|
27
|
+
NEAREST = "'nearest'"
|
|
28
|
+
WEEKDAY = "'weekday'"
|
|
29
|
+
DAY_OF_MONTH = "a day such as 15th"
|
|
30
|
+
YEAR_TARGET = "a month name or 'the'"
|
|
31
|
+
YEAR_THE = "a day such as 15th, 'last' or an ordinal such as 'first'"
|
|
32
|
+
YEAR_LAST = "'weekday' or a day name"
|
|
33
|
+
OF = "'of'"
|
|
34
|
+
MONTH_NAME = "a month name"
|
|
35
|
+
DAY_NUMBER = "a day number"
|
|
36
|
+
DATE = "a date (YYYY-MM-DD, or a month and day)"
|
|
37
|
+
ISO_DATE = "a date (YYYY-MM-DD)"
|
|
38
|
+
TIMEZONE = "a timezone"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
CLAUSE_ORDER = [
|
|
42
|
+
[TokenKind::EXCEPT, "except"],
|
|
43
|
+
[TokenKind::UNTIL, "until"],
|
|
44
|
+
[TokenKind::STARTING, "starting"],
|
|
45
|
+
[TokenKind::DURING, "during"],
|
|
46
|
+
[TokenKind::IN, "in"]
|
|
47
|
+
].freeze
|
|
48
|
+
|
|
49
|
+
private_constant :Expected, :CLAUSE_ORDER
|
|
50
|
+
|
|
51
|
+
def self.parse(input)
|
|
52
|
+
tokens = Lexer.new(input).tokenize
|
|
53
|
+
raise HronError.parse("empty expression", Span.new(0, 0), input) if tokens.empty?
|
|
54
|
+
|
|
55
|
+
new(tokens, input).parse
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.iana_timezone(name)
|
|
59
|
+
return unless name.ascii_only?
|
|
60
|
+
|
|
61
|
+
lower = name.downcase(:ascii)
|
|
62
|
+
# System zoneinfo directories that are not IANA names of their own.
|
|
63
|
+
return if lower.start_with?("systemv/", "posix/", "right/")
|
|
64
|
+
return "UTC" if lower == "utc"
|
|
65
|
+
|
|
66
|
+
iana_names[lower] if name.include?("/")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def self.iana_names
|
|
70
|
+
@iana_names ||= TZInfo::Timezone.all_identifiers.to_h { |id| [id.downcase(:ascii), id] }
|
|
71
|
+
end
|
|
72
|
+
private_class_method :iana_names
|
|
73
|
+
|
|
11
74
|
def initialize(tokens, input)
|
|
12
75
|
@tokens = tokens
|
|
13
76
|
@pos = 0
|
|
14
77
|
@input = input
|
|
78
|
+
@chars = Utf8.convert(input).chars
|
|
79
|
+
@until_span = nil
|
|
15
80
|
end
|
|
16
81
|
|
|
82
|
+
def parse
|
|
83
|
+
schedule = parse_clauses(parse_expression)
|
|
84
|
+
raise leftover(schedule) if peek
|
|
85
|
+
|
|
86
|
+
# spec/README.md, "Parse errors": every other error wins over a named until without starting.
|
|
87
|
+
check_named_until(schedule)
|
|
88
|
+
schedule
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
17
93
|
def peek
|
|
18
|
-
|
|
94
|
+
@tokens[@pos]
|
|
19
95
|
end
|
|
20
96
|
|
|
21
97
|
def peek_kind
|
|
22
|
-
|
|
23
|
-
tok&.kind
|
|
98
|
+
peek&.kind
|
|
24
99
|
end
|
|
25
100
|
|
|
26
101
|
def advance
|
|
27
|
-
|
|
28
|
-
@pos += 1
|
|
29
|
-
|
|
102
|
+
token = @tokens[@pos]
|
|
103
|
+
@pos += 1
|
|
104
|
+
token
|
|
30
105
|
end
|
|
31
106
|
|
|
32
|
-
def
|
|
33
|
-
|
|
34
|
-
|
|
107
|
+
def previous
|
|
108
|
+
@tokens[@pos - 1]
|
|
109
|
+
end
|
|
35
110
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
Span.new(0, 0)
|
|
41
|
-
end
|
|
111
|
+
def eat(kind)
|
|
112
|
+
found = peek_kind == kind
|
|
113
|
+
@pos += 1 if found
|
|
114
|
+
found
|
|
42
115
|
end
|
|
43
116
|
|
|
44
|
-
def
|
|
45
|
-
|
|
117
|
+
def expect(kind, what)
|
|
118
|
+
raise expected(what) unless eat(kind)
|
|
46
119
|
end
|
|
47
120
|
|
|
48
|
-
def
|
|
49
|
-
|
|
50
|
-
end_pos = @tokens.last.span.end_pos
|
|
51
|
-
span = Span.new(end_pos, end_pos)
|
|
52
|
-
else
|
|
53
|
-
span = Span.new(0, 0)
|
|
54
|
-
end
|
|
55
|
-
HronError.parse(message, span, @input)
|
|
121
|
+
def text(token)
|
|
122
|
+
@chars[token.span.start...token.span.end_pos].join
|
|
56
123
|
end
|
|
57
124
|
|
|
58
|
-
def
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if tok && tok.kind.is_a?(check_class)
|
|
62
|
-
@pos += 1
|
|
63
|
-
return tok
|
|
64
|
-
end
|
|
65
|
-
raise error("expected #{expected}, got #{tok.kind.class.name.split("::").last}", span) if tok
|
|
125
|
+
def error(message, start, stop, suggestion: nil)
|
|
126
|
+
HronError.parse(message, Span.new(start, stop), @input, suggestion: suggestion)
|
|
127
|
+
end
|
|
66
128
|
|
|
67
|
-
|
|
129
|
+
def token_error(message, token)
|
|
130
|
+
error(message, token.span.start, token.span.end_pos)
|
|
68
131
|
end
|
|
69
132
|
|
|
70
|
-
def
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
if tok && tok.kind == kind_symbol
|
|
74
|
-
@pos += 1
|
|
75
|
-
return tok
|
|
76
|
-
end
|
|
77
|
-
raise error("expected #{expected}", span) if tok
|
|
133
|
+
def expected(what)
|
|
134
|
+
token = peek
|
|
135
|
+
return token_error("expected #{what}, got '#{text(token)}'", token) if token
|
|
78
136
|
|
|
79
|
-
|
|
137
|
+
stop = @tokens.last&.span&.end_pos || 0
|
|
138
|
+
error("expected #{what}, got end of input", stop, stop)
|
|
80
139
|
end
|
|
81
140
|
|
|
82
|
-
# --- Grammar productions ---
|
|
83
|
-
|
|
84
141
|
def parse_expression
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
when TokenKind::EVERY
|
|
90
|
-
advance
|
|
91
|
-
expr = parse_every
|
|
92
|
-
when TokenKind::ON
|
|
93
|
-
advance
|
|
94
|
-
expr = parse_on
|
|
142
|
+
if eat(TokenKind::EVERY)
|
|
143
|
+
parse_every
|
|
144
|
+
elsif eat(TokenKind::ON)
|
|
145
|
+
parse_on
|
|
95
146
|
else
|
|
96
|
-
raise
|
|
147
|
+
raise expected(Expected::EVERY_OR_ON)
|
|
97
148
|
end
|
|
98
|
-
|
|
99
|
-
parse_trailing_clauses(expr)
|
|
100
149
|
end
|
|
101
150
|
|
|
102
|
-
def
|
|
103
|
-
|
|
151
|
+
def parse_clauses(expr)
|
|
152
|
+
fields = {expression: expr}
|
|
153
|
+
fields[:except] = parse_exception_list if eat(TokenKind::EXCEPT)
|
|
104
154
|
|
|
105
|
-
# except
|
|
106
|
-
if peek_kind == TokenKind::EXCEPT
|
|
107
|
-
advance
|
|
108
|
-
schedule = ScheduleData.new(
|
|
109
|
-
expr: schedule.expr,
|
|
110
|
-
timezone: schedule.timezone,
|
|
111
|
-
except: parse_exception_list,
|
|
112
|
-
until: schedule.until,
|
|
113
|
-
anchor: schedule.anchor,
|
|
114
|
-
during: schedule.during
|
|
115
|
-
)
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
# until
|
|
119
155
|
if peek_kind == TokenKind::UNTIL
|
|
120
|
-
advance
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
except: schedule.except,
|
|
125
|
-
until: parse_until_spec,
|
|
126
|
-
anchor: schedule.anchor,
|
|
127
|
-
during: schedule.during
|
|
128
|
-
)
|
|
156
|
+
until_token = advance
|
|
157
|
+
date = parse_date
|
|
158
|
+
fields[:until] = date.is_a?(IsoDate) ? IsoUntil.new(date.date) : NamedUntil.new(date.month, date.day)
|
|
159
|
+
@until_span = [until_token.span.start, previous.span.end_pos]
|
|
129
160
|
end
|
|
130
161
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
advance
|
|
134
|
-
k = peek_kind
|
|
135
|
-
raise error("expected ISO date (YYYY-MM-DD) after 'starting'", current_span) unless k.is_a?(TIsoDate)
|
|
136
|
-
|
|
137
|
-
validate_iso_date(k.date)
|
|
138
|
-
anchor = k.date
|
|
139
|
-
advance
|
|
140
|
-
schedule = ScheduleData.new(
|
|
141
|
-
expr: schedule.expr,
|
|
142
|
-
timezone: schedule.timezone,
|
|
143
|
-
except: schedule.except,
|
|
144
|
-
until: schedule.until,
|
|
145
|
-
anchor: anchor,
|
|
146
|
-
during: schedule.during
|
|
147
|
-
)
|
|
162
|
+
if eat(TokenKind::STARTING)
|
|
163
|
+
raise expected(Expected::ISO_DATE) unless peek_kind.is_a?(TIsoDate)
|
|
148
164
|
|
|
165
|
+
fields[:starting] = iso_date(advance)
|
|
149
166
|
end
|
|
150
167
|
|
|
151
|
-
|
|
152
|
-
if peek_kind == TokenKind::DURING
|
|
153
|
-
advance
|
|
154
|
-
schedule = ScheduleData.new(
|
|
155
|
-
expr: schedule.expr,
|
|
156
|
-
timezone: schedule.timezone,
|
|
157
|
-
except: schedule.except,
|
|
158
|
-
until: schedule.until,
|
|
159
|
-
anchor: schedule.anchor,
|
|
160
|
-
during: parse_month_list
|
|
161
|
-
)
|
|
162
|
-
end
|
|
168
|
+
fields[:during] = parse_month_list if eat(TokenKind::DURING)
|
|
163
169
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
advance
|
|
167
|
-
k = peek_kind
|
|
168
|
-
raise error("expected timezone after 'in'", current_span) unless k.is_a?(TTimezone)
|
|
170
|
+
if eat(TokenKind::IN)
|
|
171
|
+
raise expected(Expected::TIMEZONE) unless peek_kind.is_a?(TTimezone)
|
|
169
172
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
except: schedule.except,
|
|
176
|
-
until: schedule.until,
|
|
177
|
-
anchor: schedule.anchor,
|
|
178
|
-
during: schedule.during
|
|
179
|
-
)
|
|
173
|
+
fields[:timezone] = timezone(advance)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
ScheduleData.new(**fields)
|
|
177
|
+
end
|
|
180
178
|
|
|
179
|
+
def leftover(schedule)
|
|
180
|
+
token = peek
|
|
181
|
+
# Every clause holds at least one item, so a clause was read exactly when its field is set.
|
|
182
|
+
read = [schedule.except.any?, !schedule.until.nil?, !schedule.starting.nil?, schedule.during.any?, !schedule.timezone.nil?]
|
|
183
|
+
clause = CLAUSE_ORDER.index { |kind, _| kind == token.kind }
|
|
184
|
+
last_read = read.rindex(true)
|
|
185
|
+
message = if clause && read[clause]
|
|
186
|
+
"duplicate '#{CLAUSE_ORDER[clause][1]}' clause"
|
|
187
|
+
elsif clause && last_read
|
|
188
|
+
"'#{CLAUSE_ORDER[clause][1]}' must come before '#{CLAUSE_ORDER[last_read][1]}'"
|
|
189
|
+
else
|
|
190
|
+
"unexpected '#{text(token)}' after the schedule"
|
|
181
191
|
end
|
|
192
|
+
token_error(message, token)
|
|
193
|
+
end
|
|
182
194
|
|
|
183
|
-
|
|
195
|
+
def check_named_until(schedule)
|
|
196
|
+
named = schedule.until
|
|
197
|
+
return unless named.is_a?(NamedUntil) && schedule.starting.nil?
|
|
198
|
+
|
|
199
|
+
raise error(
|
|
200
|
+
"until #{named.month} #{named.day} has no year: add a starting date, or use an ISO date",
|
|
201
|
+
*@until_span,
|
|
202
|
+
suggestion: "until #{named.month} #{named.day} starting YYYY-MM-DD"
|
|
203
|
+
)
|
|
184
204
|
end
|
|
185
205
|
|
|
186
206
|
def parse_exception_list
|
|
187
207
|
exceptions = [parse_exception]
|
|
188
|
-
|
|
189
|
-
advance
|
|
190
|
-
exceptions << parse_exception
|
|
191
|
-
end
|
|
208
|
+
exceptions << parse_exception while eat(TokenKind::COMMA)
|
|
192
209
|
exceptions
|
|
193
210
|
end
|
|
194
211
|
|
|
195
|
-
def
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
raise error("invalid date: #{date_str}", current_span)
|
|
199
|
-
end
|
|
200
|
-
|
|
201
|
-
def validate_named_date(month, day)
|
|
202
|
-
max = case month
|
|
203
|
-
when MonthName::JAN then 31
|
|
204
|
-
when MonthName::FEB then 29
|
|
205
|
-
when MonthName::MAR then 31
|
|
206
|
-
when MonthName::APR then 30
|
|
207
|
-
when MonthName::MAY then 31
|
|
208
|
-
when MonthName::JUN then 30
|
|
209
|
-
when MonthName::JUL then 31
|
|
210
|
-
when MonthName::AUG then 31
|
|
211
|
-
when MonthName::SEP then 30
|
|
212
|
-
when MonthName::OCT then 31
|
|
213
|
-
when MonthName::NOV then 30
|
|
214
|
-
when MonthName::DEC then 31
|
|
215
|
-
end
|
|
216
|
-
return if day.between?(1, max)
|
|
217
|
-
|
|
218
|
-
raise error("invalid day #{day} for #{month} (max #{max})", current_span)
|
|
212
|
+
def parse_exception
|
|
213
|
+
date = parse_date
|
|
214
|
+
date.is_a?(IsoDate) ? IsoException.new(date.date) : NamedException.new(date.month, date.day)
|
|
219
215
|
end
|
|
220
216
|
|
|
221
|
-
def
|
|
222
|
-
|
|
223
|
-
if
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
end
|
|
228
|
-
if k.is_a?(TMonthName)
|
|
229
|
-
month = k.name
|
|
217
|
+
def parse_date
|
|
218
|
+
kind = peek_kind
|
|
219
|
+
if kind.is_a?(TIsoDate)
|
|
220
|
+
iso_date(advance)
|
|
221
|
+
IsoDate.new(kind.date)
|
|
222
|
+
elsif kind.is_a?(TMonthName)
|
|
230
223
|
advance
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
224
|
+
NamedDate.new(kind.name, parse_day_of(kind.name))
|
|
225
|
+
else
|
|
226
|
+
raise expected(Expected::DATE)
|
|
234
227
|
end
|
|
235
|
-
raise error("expected ISO date or month-day in exception", current_span)
|
|
236
228
|
end
|
|
237
229
|
|
|
238
|
-
def
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
end
|
|
245
|
-
if k.is_a?(TMonthName)
|
|
246
|
-
month = k.name
|
|
247
|
-
advance
|
|
248
|
-
day = parse_day_number("expected day number after month name in until")
|
|
249
|
-
validate_named_date(month, day)
|
|
250
|
-
return NamedUntil.new(month, day)
|
|
251
|
-
end
|
|
252
|
-
raise error("expected ISO date or month-day after 'until'", current_span)
|
|
230
|
+
def iso_date(token)
|
|
231
|
+
written = text(token)
|
|
232
|
+
year, month, day = written.split("-").map(&:to_i)
|
|
233
|
+
return written if year >= 1 && Date.valid_date?(year, month, day, Date::GREGORIAN)
|
|
234
|
+
|
|
235
|
+
raise token_error("date must be a calendar date from 0001-01-01 to 9999-12-31, got #{written}", token)
|
|
253
236
|
end
|
|
254
237
|
|
|
255
|
-
def
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
if val < 1 || val > 31
|
|
260
|
-
raise error("invalid day number #{val} (must be 1-31)", current_span)
|
|
261
|
-
end
|
|
262
|
-
advance
|
|
263
|
-
return val
|
|
264
|
-
end
|
|
265
|
-
if k.is_a?(TOrdinalNumber)
|
|
266
|
-
val = k.value
|
|
267
|
-
if val < 1 || val > 31
|
|
268
|
-
raise error("invalid day number #{val} (must be 1-31)", current_span)
|
|
269
|
-
end
|
|
270
|
-
advance
|
|
271
|
-
return val
|
|
272
|
-
end
|
|
273
|
-
raise error(error_msg, current_span)
|
|
238
|
+
def timezone(token)
|
|
239
|
+
name = text(token)
|
|
240
|
+
Parser.iana_timezone(name) or
|
|
241
|
+
raise token_error("timezone must be UTC or an Area/Location name such as America/New_York, got #{name}", token)
|
|
274
242
|
end
|
|
275
243
|
|
|
276
|
-
# After "every": dispatch
|
|
277
244
|
def parse_every
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
k = peek_kind
|
|
281
|
-
|
|
282
|
-
case k
|
|
283
|
-
when TokenKind::YEAR
|
|
284
|
-
advance
|
|
285
|
-
parse_year_repeat(1)
|
|
245
|
+
kind = peek_kind
|
|
246
|
+
case kind
|
|
286
247
|
when TokenKind::DAY
|
|
248
|
+
advance
|
|
287
249
|
parse_day_repeat(1, DayFilterEvery.new)
|
|
288
250
|
when TokenKind::WEEKDAY_KW
|
|
289
251
|
advance
|
|
@@ -292,393 +254,298 @@ module Hron
|
|
|
292
254
|
advance
|
|
293
255
|
parse_day_repeat(1, DayFilterWeekend.new)
|
|
294
256
|
when TDayName
|
|
295
|
-
|
|
296
|
-
|
|
257
|
+
parse_day_repeat(1, DayFilterDays.new(parse_day_list))
|
|
258
|
+
when TokenKind::WEEKS
|
|
259
|
+
advance
|
|
260
|
+
parse_week_repeat(1)
|
|
297
261
|
when TokenKind::MONTH
|
|
298
262
|
advance
|
|
299
263
|
parse_month_repeat(1)
|
|
300
|
-
when TokenKind::
|
|
264
|
+
when TokenKind::YEAR
|
|
301
265
|
advance
|
|
302
|
-
|
|
266
|
+
parse_year_repeat(1)
|
|
303
267
|
when TNumber
|
|
304
|
-
parse_number_repeat
|
|
268
|
+
parse_number_repeat(kind.value)
|
|
305
269
|
else
|
|
306
|
-
raise
|
|
307
|
-
"expected day, weekday, weekend, year, day name, month, or number after 'every'",
|
|
308
|
-
current_span
|
|
309
|
-
)
|
|
270
|
+
raise expected(Expected::REPEATER)
|
|
310
271
|
end
|
|
311
272
|
end
|
|
312
273
|
|
|
313
274
|
def parse_day_repeat(interval, days)
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
times = parse_time_list
|
|
317
|
-
DayRepeat.new(interval, days, times)
|
|
275
|
+
expect(TokenKind::AT, Expected::AT)
|
|
276
|
+
DayRepeat.new(interval, days, parse_time_list)
|
|
318
277
|
end
|
|
319
278
|
|
|
320
|
-
def parse_number_repeat
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
num = k.value
|
|
324
|
-
raise error("interval must be at least 1", span) if num.zero?
|
|
325
|
-
|
|
326
|
-
advance
|
|
279
|
+
def parse_number_repeat(interval)
|
|
280
|
+
number = advance
|
|
281
|
+
raise token_error("interval must be 1-2147483647, got #{text(number)}", number) if interval.zero?
|
|
327
282
|
|
|
328
|
-
|
|
329
|
-
case
|
|
283
|
+
kind = peek_kind
|
|
284
|
+
case kind
|
|
330
285
|
when TokenKind::WEEKS
|
|
331
286
|
advance
|
|
332
|
-
parse_week_repeat(
|
|
287
|
+
parse_week_repeat(interval)
|
|
333
288
|
when TIntervalUnit
|
|
334
|
-
|
|
289
|
+
advance
|
|
290
|
+
parse_interval_repeat(interval, kind.unit)
|
|
335
291
|
when TokenKind::DAY
|
|
336
|
-
|
|
292
|
+
advance
|
|
293
|
+
parse_day_repeat(interval, DayFilterEvery.new)
|
|
337
294
|
when TokenKind::MONTH
|
|
338
295
|
advance
|
|
339
|
-
parse_month_repeat(
|
|
296
|
+
parse_month_repeat(interval)
|
|
340
297
|
when TokenKind::YEAR
|
|
341
298
|
advance
|
|
342
|
-
parse_year_repeat(
|
|
299
|
+
parse_year_repeat(interval)
|
|
343
300
|
else
|
|
344
|
-
raise
|
|
345
|
-
"expected 'weeks', 'min', 'minutes', 'hour', 'hours', 'day(s)', 'month(s)', or 'year(s)' after number",
|
|
346
|
-
current_span
|
|
347
|
-
)
|
|
301
|
+
raise expected(Expected::UNIT)
|
|
348
302
|
end
|
|
349
303
|
end
|
|
350
304
|
|
|
351
|
-
def parse_interval_repeat(interval)
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
advance
|
|
364
|
-
day_filter = parse_day_target
|
|
305
|
+
def parse_interval_repeat(interval, unit)
|
|
306
|
+
expect(TokenKind::FROM, Expected::FROM)
|
|
307
|
+
from = parse_time
|
|
308
|
+
from_token = previous
|
|
309
|
+
expect(TokenKind::TO, Expected::TO)
|
|
310
|
+
to = parse_time
|
|
311
|
+
to_token = previous
|
|
312
|
+
if (from.hour * 60) + from.minute > (to.hour * 60) + to.minute
|
|
313
|
+
raise error(
|
|
314
|
+
"time window must not run backwards: #{text(from_token)} to #{text(to_token)} (a window cannot cross midnight)",
|
|
315
|
+
from_token.span.start, to_token.span.end_pos
|
|
316
|
+
)
|
|
365
317
|
end
|
|
366
318
|
|
|
367
|
-
|
|
319
|
+
day_filter = eat(TokenKind::ON) ? parse_day_target : nil
|
|
320
|
+
IntervalRepeat.new(interval, unit, from, to, day_filter)
|
|
368
321
|
end
|
|
369
322
|
|
|
370
323
|
def parse_week_repeat(interval)
|
|
371
|
-
|
|
324
|
+
expect(TokenKind::ON, Expected::ON)
|
|
372
325
|
days = parse_day_list
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
WeekRepeat.new(interval, days, times)
|
|
326
|
+
expect(TokenKind::AT, Expected::AT)
|
|
327
|
+
WeekRepeat.new(interval, days, parse_time_list)
|
|
376
328
|
end
|
|
377
329
|
|
|
378
330
|
def parse_month_repeat(interval)
|
|
379
|
-
|
|
380
|
-
|
|
331
|
+
expect(TokenKind::ON, Expected::ON)
|
|
332
|
+
expect(TokenKind::THE, Expected::THE)
|
|
381
333
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
if k == TokenKind::LAST
|
|
334
|
+
kind = peek_kind
|
|
335
|
+
target = if kind == TokenKind::LAST
|
|
385
336
|
advance
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
advance
|
|
389
|
-
target = LastDayTarget.new
|
|
390
|
-
elsif nk == TokenKind::WEEKDAY_KW
|
|
391
|
-
advance
|
|
392
|
-
target = LastWeekdayTarget.new
|
|
393
|
-
elsif nk.is_a?(TDayName)
|
|
394
|
-
weekday = nk.name
|
|
395
|
-
advance
|
|
396
|
-
target = OrdinalWeekdayTarget.new(OrdinalPosition::LAST, weekday)
|
|
397
|
-
else
|
|
398
|
-
raise error("expected 'day', 'weekday', or day name after 'last'", current_span)
|
|
399
|
-
end
|
|
400
|
-
elsif k.is_a?(TOrdinal)
|
|
401
|
-
ordinal = parse_ordinal_position
|
|
402
|
-
nk = peek_kind
|
|
403
|
-
raise error("expected day name after ordinal", current_span) unless nk.is_a?(TDayName)
|
|
404
|
-
|
|
405
|
-
weekday = nk.name
|
|
337
|
+
parse_month_last_target
|
|
338
|
+
elsif kind.is_a?(TOrdinal)
|
|
406
339
|
advance
|
|
407
|
-
|
|
408
|
-
elsif
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
target = parse_nearest_weekday_target
|
|
340
|
+
OrdinalWeekdayTarget.new(kind.position, parse_day_name)
|
|
341
|
+
elsif kind.is_a?(TOrdinalNumber)
|
|
342
|
+
DaysTarget.new(parse_ordinal_day_list)
|
|
343
|
+
elsif [TokenKind::NEXT, TokenKind::PREVIOUS, TokenKind::NEAREST].include?(kind)
|
|
344
|
+
parse_nearest_weekday_target
|
|
413
345
|
else
|
|
414
|
-
raise
|
|
346
|
+
raise expected(Expected::MONTH_TARGET)
|
|
415
347
|
end
|
|
416
348
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
MonthRepeat.new(interval, target, times)
|
|
349
|
+
expect(TokenKind::AT, Expected::AT)
|
|
350
|
+
MonthRepeat.new(interval, target, parse_time_list)
|
|
420
351
|
end
|
|
421
352
|
|
|
422
|
-
def
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
elsif k.is_a?(TMonthName)
|
|
431
|
-
month = k.name
|
|
432
|
-
advance
|
|
433
|
-
day = parse_day_number("expected day number after month name")
|
|
434
|
-
validate_named_date(month, day)
|
|
435
|
-
target = YearDateTarget.new(month, day)
|
|
353
|
+
def parse_month_last_target
|
|
354
|
+
kind = peek_kind
|
|
355
|
+
target = if kind == TokenKind::DAY
|
|
356
|
+
LastDayTarget.new
|
|
357
|
+
elsif kind == TokenKind::WEEKDAY_KW
|
|
358
|
+
LastWeekdayTarget.new
|
|
359
|
+
elsif kind.is_a?(TDayName)
|
|
360
|
+
OrdinalWeekdayTarget.new(OrdinalPosition::LAST, kind.name)
|
|
436
361
|
else
|
|
437
|
-
raise
|
|
362
|
+
raise expected(Expected::MONTH_LAST)
|
|
438
363
|
end
|
|
364
|
+
advance
|
|
365
|
+
target
|
|
366
|
+
end
|
|
439
367
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
368
|
+
def parse_nearest_weekday_target
|
|
369
|
+
direction = if eat(TokenKind::NEXT)
|
|
370
|
+
NearestDirection::NEXT
|
|
371
|
+
elsif eat(TokenKind::PREVIOUS)
|
|
372
|
+
NearestDirection::PREVIOUS
|
|
373
|
+
end
|
|
374
|
+
expect(TokenKind::NEAREST, Expected::NEAREST)
|
|
375
|
+
expect(TokenKind::WEEKDAY_KW, Expected::WEEKDAY)
|
|
376
|
+
expect(TokenKind::TO, Expected::TO)
|
|
377
|
+
day, = parse_ordinal_day
|
|
378
|
+
NearestWeekdayTarget.new(day, direction)
|
|
443
379
|
end
|
|
444
380
|
|
|
445
|
-
def
|
|
446
|
-
|
|
381
|
+
def parse_ordinal_day_list
|
|
382
|
+
specs = [parse_ordinal_day_spec]
|
|
383
|
+
specs << parse_ordinal_day_spec while eat(TokenKind::COMMA)
|
|
384
|
+
specs
|
|
385
|
+
end
|
|
447
386
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
if nk == TokenKind::WEEKDAY_KW
|
|
452
|
-
advance
|
|
453
|
-
consume_keyword("'of'", TokenKind::OF)
|
|
454
|
-
month = parse_month_name_token
|
|
455
|
-
return YearLastWeekdayTarget.new(month)
|
|
456
|
-
end
|
|
457
|
-
if nk.is_a?(TDayName)
|
|
458
|
-
weekday = nk.name
|
|
459
|
-
advance
|
|
460
|
-
consume_keyword("'of'", TokenKind::OF)
|
|
461
|
-
month = parse_month_name_token
|
|
462
|
-
return YearOrdinalWeekdayTarget.new(OrdinalPosition::LAST, weekday, month)
|
|
463
|
-
end
|
|
464
|
-
raise error("expected 'weekday' or day name after 'last' in yearly expression", current_span)
|
|
465
|
-
end
|
|
387
|
+
def parse_ordinal_day_spec
|
|
388
|
+
start, start_token = parse_ordinal_day
|
|
389
|
+
return SingleDay.new(start) unless eat(TokenKind::TO)
|
|
466
390
|
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
consume_keyword("'of'", TokenKind::OF)
|
|
474
|
-
month = parse_month_name_token
|
|
475
|
-
return YearOrdinalWeekdayTarget.new(ordinal, weekday, month)
|
|
476
|
-
end
|
|
477
|
-
raise error("expected day name after ordinal in yearly expression", current_span)
|
|
391
|
+
stop, stop_token = parse_ordinal_day
|
|
392
|
+
if start > stop
|
|
393
|
+
raise error(
|
|
394
|
+
"day range must not run backwards: #{text(start_token)} to #{text(stop_token)}",
|
|
395
|
+
start_token.span.start, stop_token.span.end_pos
|
|
396
|
+
)
|
|
478
397
|
end
|
|
398
|
+
DayRange.new(start, stop)
|
|
399
|
+
end
|
|
479
400
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
consume_keyword("'of'", TokenKind::OF)
|
|
484
|
-
month = parse_month_name_token
|
|
485
|
-
validate_named_date(month, day)
|
|
486
|
-
return YearDayOfMonthTarget.new(day, month)
|
|
487
|
-
end
|
|
401
|
+
def parse_ordinal_day
|
|
402
|
+
kind = peek_kind
|
|
403
|
+
raise expected(Expected::DAY_OF_MONTH) unless kind.is_a?(TOrdinalNumber)
|
|
488
404
|
|
|
489
|
-
|
|
405
|
+
token = advance
|
|
406
|
+
[day_of_month(kind.value, token), token]
|
|
490
407
|
end
|
|
491
408
|
|
|
492
|
-
def
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
409
|
+
def parse_day_of(month)
|
|
410
|
+
kind = peek_kind
|
|
411
|
+
raise expected(Expected::DAY_NUMBER) unless kind.is_a?(TNumber) || kind.is_a?(TOrdinalNumber)
|
|
412
|
+
|
|
413
|
+
token = advance
|
|
414
|
+
day = day_of_month(kind.value, token)
|
|
415
|
+
check_day_in_month(day, token, month)
|
|
416
|
+
day
|
|
499
417
|
end
|
|
500
418
|
|
|
501
|
-
def
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
advance
|
|
506
|
-
return k.position
|
|
507
|
-
end
|
|
508
|
-
if k == TokenKind::LAST
|
|
509
|
-
advance
|
|
510
|
-
return OrdinalPosition::LAST
|
|
511
|
-
end
|
|
512
|
-
raise error("expected ordinal (first, second, third, fourth, fifth, last)", span)
|
|
419
|
+
def day_of_month(n, token)
|
|
420
|
+
return n if n.between?(1, 31)
|
|
421
|
+
|
|
422
|
+
raise token_error("day must be 1-31, got #{text(token)}", token)
|
|
513
423
|
end
|
|
514
424
|
|
|
515
|
-
def
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
times = parse_time_list
|
|
519
|
-
SingleDateExpr.new(date, times)
|
|
425
|
+
def check_day_in_month(day, token, month)
|
|
426
|
+
max = MonthName.max_day(month)
|
|
427
|
+
raise token_error("day must be 1-#{max} for #{month}, got #{text(token)}", token) if day > max
|
|
520
428
|
end
|
|
521
429
|
|
|
522
|
-
def
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
430
|
+
def parse_year_repeat(interval)
|
|
431
|
+
expect(TokenKind::ON, Expected::ON)
|
|
432
|
+
|
|
433
|
+
kind = peek_kind
|
|
434
|
+
target = if kind == TokenKind::THE
|
|
526
435
|
advance
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
if k.is_a?(TMonthName)
|
|
530
|
-
month = k.name
|
|
436
|
+
parse_year_target_after_the
|
|
437
|
+
elsif kind.is_a?(TMonthName)
|
|
531
438
|
advance
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
439
|
+
YearDateTarget.new(kind.name, parse_day_of(kind.name))
|
|
440
|
+
else
|
|
441
|
+
raise expected(Expected::YEAR_TARGET)
|
|
535
442
|
end
|
|
536
|
-
|
|
443
|
+
|
|
444
|
+
expect(TokenKind::AT, Expected::AT)
|
|
445
|
+
YearRepeat.new(interval, target, parse_time_list)
|
|
537
446
|
end
|
|
538
447
|
|
|
539
|
-
def
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
DayFilterDays.new(days)
|
|
448
|
+
def parse_year_target_after_the
|
|
449
|
+
kind = peek_kind
|
|
450
|
+
if kind == TokenKind::LAST
|
|
451
|
+
advance
|
|
452
|
+
parse_year_last_target
|
|
453
|
+
elsif kind.is_a?(TOrdinal)
|
|
454
|
+
advance
|
|
455
|
+
weekday = parse_day_name
|
|
456
|
+
YearOrdinalWeekdayTarget.new(kind.position, weekday, parse_of_month)
|
|
457
|
+
elsif kind.is_a?(TOrdinalNumber)
|
|
458
|
+
day, day_token = parse_ordinal_day
|
|
459
|
+
month = parse_of_month
|
|
460
|
+
check_day_in_month(day, day_token, month)
|
|
461
|
+
YearDayOfMonthTarget.new(day, month)
|
|
554
462
|
else
|
|
555
|
-
raise
|
|
463
|
+
raise expected(Expected::YEAR_THE)
|
|
556
464
|
end
|
|
557
465
|
end
|
|
558
466
|
|
|
559
|
-
def
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
days = [k.name]
|
|
564
|
-
advance
|
|
565
|
-
|
|
566
|
-
while peek_kind == TokenKind::COMMA
|
|
467
|
+
def parse_year_last_target
|
|
468
|
+
kind = peek_kind
|
|
469
|
+
if kind == TokenKind::WEEKDAY_KW
|
|
567
470
|
advance
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
days << nk.name
|
|
471
|
+
YearLastWeekdayTarget.new(parse_of_month)
|
|
472
|
+
elsif kind.is_a?(TDayName)
|
|
572
473
|
advance
|
|
474
|
+
YearOrdinalWeekdayTarget.new(OrdinalPosition::LAST, kind.name, parse_of_month)
|
|
475
|
+
else
|
|
476
|
+
raise expected(Expected::YEAR_LAST)
|
|
573
477
|
end
|
|
574
|
-
days
|
|
575
478
|
end
|
|
576
479
|
|
|
577
|
-
def
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
advance
|
|
581
|
-
specs << parse_ordinal_day_spec
|
|
582
|
-
end
|
|
583
|
-
specs
|
|
480
|
+
def parse_of_month
|
|
481
|
+
expect(TokenKind::OF, Expected::OF)
|
|
482
|
+
parse_month_name
|
|
584
483
|
end
|
|
585
484
|
|
|
586
|
-
def
|
|
587
|
-
|
|
588
|
-
raise
|
|
485
|
+
def parse_month_name
|
|
486
|
+
kind = peek_kind
|
|
487
|
+
raise expected(Expected::MONTH_NAME) unless kind.is_a?(TMonthName)
|
|
589
488
|
|
|
590
|
-
start = k.value
|
|
591
|
-
span = current_span
|
|
592
|
-
raise error("invalid day number #{start} (must be 1-31)", span) if start < 1 || start > 31
|
|
593
489
|
advance
|
|
490
|
+
kind.name
|
|
491
|
+
end
|
|
594
492
|
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
end_day = nk.value
|
|
601
|
-
end_span = current_span
|
|
602
|
-
raise error("invalid day number #{end_day} (must be 1-31)", end_span) if end_day < 1 || end_day > 31
|
|
603
|
-
advance
|
|
604
|
-
raise error("invalid day range: #{start} to #{end_day} (start must be <= end)", current_span) if start > end_day
|
|
605
|
-
return DayRange.new(start, end_day)
|
|
606
|
-
end
|
|
607
|
-
|
|
608
|
-
SingleDay.new(start)
|
|
493
|
+
def parse_month_list
|
|
494
|
+
months = [parse_month_name]
|
|
495
|
+
months << parse_month_name while eat(TokenKind::COMMA)
|
|
496
|
+
months
|
|
609
497
|
end
|
|
610
498
|
|
|
611
|
-
def
|
|
612
|
-
|
|
499
|
+
def parse_on
|
|
500
|
+
date = parse_date
|
|
501
|
+
expect(TokenKind::AT, Expected::AT)
|
|
502
|
+
SingleDateExpr.new(date, parse_time_list)
|
|
503
|
+
end
|
|
613
504
|
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
505
|
+
def parse_day_target
|
|
506
|
+
case peek_kind
|
|
507
|
+
when TokenKind::DAY
|
|
617
508
|
advance
|
|
618
|
-
|
|
619
|
-
|
|
509
|
+
DayFilterEvery.new
|
|
510
|
+
when TokenKind::WEEKDAY_KW
|
|
620
511
|
advance
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
consume_keyword("'nearest'", TokenKind::NEAREST)
|
|
625
|
-
consume_keyword("'weekday'", TokenKind::WEEKDAY_KW)
|
|
626
|
-
consume_keyword("'to'", TokenKind::TO)
|
|
627
|
-
|
|
628
|
-
# Parse the day number (ordinal like 15th)
|
|
629
|
-
k = peek_kind
|
|
630
|
-
if k.is_a?(TOrdinalNumber)
|
|
631
|
-
day = k.value
|
|
632
|
-
if day < 1 || day > 31
|
|
633
|
-
raise error("invalid day number #{day} (must be 1-31)", current_span)
|
|
634
|
-
end
|
|
512
|
+
DayFilterWeekday.new
|
|
513
|
+
when TokenKind::WEEKEND_KW
|
|
635
514
|
advance
|
|
515
|
+
DayFilterWeekend.new
|
|
516
|
+
when TDayName
|
|
517
|
+
DayFilterDays.new(parse_day_list)
|
|
636
518
|
else
|
|
637
|
-
raise
|
|
519
|
+
raise expected(Expected::DAY_TARGET)
|
|
638
520
|
end
|
|
521
|
+
end
|
|
639
522
|
|
|
640
|
-
|
|
523
|
+
def parse_day_name
|
|
524
|
+
kind = peek_kind
|
|
525
|
+
raise expected(Expected::DAY_NAME) unless kind.is_a?(TDayName)
|
|
526
|
+
|
|
527
|
+
advance
|
|
528
|
+
kind.name
|
|
641
529
|
end
|
|
642
530
|
|
|
643
|
-
def
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
months << parse_month_name_token
|
|
648
|
-
end
|
|
649
|
-
months
|
|
531
|
+
def parse_day_list
|
|
532
|
+
days = [parse_day_name]
|
|
533
|
+
days << parse_day_name while eat(TokenKind::COMMA)
|
|
534
|
+
days
|
|
650
535
|
end
|
|
651
536
|
|
|
652
537
|
def parse_time_list
|
|
653
538
|
times = [parse_time]
|
|
654
|
-
|
|
655
|
-
advance
|
|
656
|
-
times << parse_time
|
|
657
|
-
end
|
|
539
|
+
times << parse_time while eat(TokenKind::COMMA)
|
|
658
540
|
times
|
|
659
541
|
end
|
|
660
542
|
|
|
661
543
|
def parse_time
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
if k.is_a?(TTime)
|
|
665
|
-
advance
|
|
666
|
-
return TimeOfDay.new(k.hour, k.minute)
|
|
667
|
-
end
|
|
668
|
-
raise error("expected time (HH:MM)", span)
|
|
669
|
-
end
|
|
670
|
-
end
|
|
671
|
-
|
|
672
|
-
def self.parse(input)
|
|
673
|
-
tokens = tokenize(input)
|
|
674
|
-
|
|
675
|
-
raise HronError.parse("empty expression", Span.new(0, 0), input) if tokens.empty?
|
|
676
|
-
|
|
677
|
-
parser = Parser.new(tokens, input)
|
|
678
|
-
schedule = parser.parse_expression
|
|
679
|
-
|
|
680
|
-
raise HronError.parse("unexpected tokens after expression", parser.current_span, input) if parser.peek
|
|
544
|
+
kind = peek_kind
|
|
545
|
+
raise expected(Expected::TIME) unless kind.is_a?(TTime)
|
|
681
546
|
|
|
682
|
-
|
|
547
|
+
advance
|
|
548
|
+
TimeOfDay.new(kind.hour, kind.minute)
|
|
549
|
+
end
|
|
683
550
|
end
|
|
684
551
|
end
|