hron 0.6.1 → 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 +11 -5
- 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 +370 -500
- 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
|
-
|
|
104
|
-
|
|
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
|
|
151
|
+
def parse_clauses(expr)
|
|
152
|
+
fields = {expression: expr}
|
|
153
|
+
fields[:except] = parse_exception_list if eat(TokenKind::EXCEPT)
|
|
117
154
|
|
|
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,390 +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)
|
|
264
|
+
when TokenKind::YEAR
|
|
265
|
+
advance
|
|
266
|
+
parse_year_repeat(1)
|
|
300
267
|
when TNumber
|
|
301
|
-
parse_number_repeat
|
|
268
|
+
parse_number_repeat(kind.value)
|
|
302
269
|
else
|
|
303
|
-
raise
|
|
304
|
-
"expected day, weekday, weekend, year, day name, month, or number after 'every'",
|
|
305
|
-
current_span
|
|
306
|
-
)
|
|
270
|
+
raise expected(Expected::REPEATER)
|
|
307
271
|
end
|
|
308
272
|
end
|
|
309
273
|
|
|
310
274
|
def parse_day_repeat(interval, days)
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
times = parse_time_list
|
|
314
|
-
DayRepeat.new(interval, days, times)
|
|
275
|
+
expect(TokenKind::AT, Expected::AT)
|
|
276
|
+
DayRepeat.new(interval, days, parse_time_list)
|
|
315
277
|
end
|
|
316
278
|
|
|
317
|
-
def parse_number_repeat
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
num = k.value
|
|
321
|
-
raise error("interval must be at least 1", span) if num.zero?
|
|
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?
|
|
322
282
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
nk = peek_kind
|
|
326
|
-
case nk
|
|
283
|
+
kind = peek_kind
|
|
284
|
+
case kind
|
|
327
285
|
when TokenKind::WEEKS
|
|
328
286
|
advance
|
|
329
|
-
parse_week_repeat(
|
|
287
|
+
parse_week_repeat(interval)
|
|
330
288
|
when TIntervalUnit
|
|
331
|
-
|
|
289
|
+
advance
|
|
290
|
+
parse_interval_repeat(interval, kind.unit)
|
|
332
291
|
when TokenKind::DAY
|
|
333
|
-
|
|
292
|
+
advance
|
|
293
|
+
parse_day_repeat(interval, DayFilterEvery.new)
|
|
334
294
|
when TokenKind::MONTH
|
|
335
295
|
advance
|
|
336
|
-
parse_month_repeat(
|
|
296
|
+
parse_month_repeat(interval)
|
|
337
297
|
when TokenKind::YEAR
|
|
338
298
|
advance
|
|
339
|
-
parse_year_repeat(
|
|
299
|
+
parse_year_repeat(interval)
|
|
340
300
|
else
|
|
341
|
-
raise
|
|
342
|
-
"expected 'weeks', 'min', 'minutes', 'hour', 'hours', 'day(s)', 'month(s)', or 'year(s)' after number",
|
|
343
|
-
current_span
|
|
344
|
-
)
|
|
301
|
+
raise expected(Expected::UNIT)
|
|
345
302
|
end
|
|
346
303
|
end
|
|
347
304
|
|
|
348
|
-
def parse_interval_repeat(interval)
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
advance
|
|
361
|
-
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
|
+
)
|
|
362
317
|
end
|
|
363
318
|
|
|
364
|
-
|
|
319
|
+
day_filter = eat(TokenKind::ON) ? parse_day_target : nil
|
|
320
|
+
IntervalRepeat.new(interval, unit, from, to, day_filter)
|
|
365
321
|
end
|
|
366
322
|
|
|
367
323
|
def parse_week_repeat(interval)
|
|
368
|
-
|
|
324
|
+
expect(TokenKind::ON, Expected::ON)
|
|
369
325
|
days = parse_day_list
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
WeekRepeat.new(interval, days, times)
|
|
326
|
+
expect(TokenKind::AT, Expected::AT)
|
|
327
|
+
WeekRepeat.new(interval, days, parse_time_list)
|
|
373
328
|
end
|
|
374
329
|
|
|
375
330
|
def parse_month_repeat(interval)
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
k = peek_kind
|
|
331
|
+
expect(TokenKind::ON, Expected::ON)
|
|
332
|
+
expect(TokenKind::THE, Expected::THE)
|
|
380
333
|
|
|
381
|
-
|
|
334
|
+
kind = peek_kind
|
|
335
|
+
target = if kind == TokenKind::LAST
|
|
382
336
|
advance
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
advance
|
|
386
|
-
target = LastDayTarget.new
|
|
387
|
-
elsif nk == TokenKind::WEEKDAY_KW
|
|
388
|
-
advance
|
|
389
|
-
target = LastWeekdayTarget.new
|
|
390
|
-
elsif nk.is_a?(TDayName)
|
|
391
|
-
weekday = nk.name
|
|
392
|
-
advance
|
|
393
|
-
target = OrdinalWeekdayTarget.new(OrdinalPosition::LAST, weekday)
|
|
394
|
-
else
|
|
395
|
-
raise error("expected 'day', 'weekday', or day name after 'last'", current_span)
|
|
396
|
-
end
|
|
397
|
-
elsif k.is_a?(TOrdinal)
|
|
398
|
-
ordinal = parse_ordinal_position
|
|
399
|
-
nk = peek_kind
|
|
400
|
-
raise error("expected day name after ordinal", current_span) unless nk.is_a?(TDayName)
|
|
401
|
-
|
|
402
|
-
weekday = nk.name
|
|
337
|
+
parse_month_last_target
|
|
338
|
+
elsif kind.is_a?(TOrdinal)
|
|
403
339
|
advance
|
|
404
|
-
|
|
405
|
-
elsif
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
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
|
|
410
345
|
else
|
|
411
|
-
raise
|
|
346
|
+
raise expected(Expected::MONTH_TARGET)
|
|
412
347
|
end
|
|
413
348
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
MonthRepeat.new(interval, target, times)
|
|
349
|
+
expect(TokenKind::AT, Expected::AT)
|
|
350
|
+
MonthRepeat.new(interval, target, parse_time_list)
|
|
417
351
|
end
|
|
418
352
|
|
|
419
|
-
def
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
elsif k.is_a?(TMonthName)
|
|
428
|
-
month = k.name
|
|
429
|
-
advance
|
|
430
|
-
day = parse_day_number("expected day number after month name")
|
|
431
|
-
validate_named_date(month, day)
|
|
432
|
-
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)
|
|
433
361
|
else
|
|
434
|
-
raise
|
|
362
|
+
raise expected(Expected::MONTH_LAST)
|
|
435
363
|
end
|
|
364
|
+
advance
|
|
365
|
+
target
|
|
366
|
+
end
|
|
436
367
|
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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)
|
|
440
379
|
end
|
|
441
380
|
|
|
442
|
-
def
|
|
443
|
-
|
|
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
|
|
444
386
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
if nk == TokenKind::WEEKDAY_KW
|
|
449
|
-
advance
|
|
450
|
-
consume_keyword("'of'", TokenKind::OF)
|
|
451
|
-
month = parse_month_name_token
|
|
452
|
-
return YearLastWeekdayTarget.new(month)
|
|
453
|
-
end
|
|
454
|
-
if nk.is_a?(TDayName)
|
|
455
|
-
weekday = nk.name
|
|
456
|
-
advance
|
|
457
|
-
consume_keyword("'of'", TokenKind::OF)
|
|
458
|
-
month = parse_month_name_token
|
|
459
|
-
return YearOrdinalWeekdayTarget.new(OrdinalPosition::LAST, weekday, month)
|
|
460
|
-
end
|
|
461
|
-
raise error("expected 'weekday' or day name after 'last' in yearly expression", current_span)
|
|
462
|
-
end
|
|
387
|
+
def parse_ordinal_day_spec
|
|
388
|
+
start, start_token = parse_ordinal_day
|
|
389
|
+
return SingleDay.new(start) unless eat(TokenKind::TO)
|
|
463
390
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
consume_keyword("'of'", TokenKind::OF)
|
|
471
|
-
month = parse_month_name_token
|
|
472
|
-
return YearOrdinalWeekdayTarget.new(ordinal, weekday, month)
|
|
473
|
-
end
|
|
474
|
-
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
|
+
)
|
|
475
397
|
end
|
|
398
|
+
DayRange.new(start, stop)
|
|
399
|
+
end
|
|
476
400
|
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
consume_keyword("'of'", TokenKind::OF)
|
|
481
|
-
month = parse_month_name_token
|
|
482
|
-
validate_named_date(month, day)
|
|
483
|
-
return YearDayOfMonthTarget.new(day, month)
|
|
484
|
-
end
|
|
401
|
+
def parse_ordinal_day
|
|
402
|
+
kind = peek_kind
|
|
403
|
+
raise expected(Expected::DAY_OF_MONTH) unless kind.is_a?(TOrdinalNumber)
|
|
485
404
|
|
|
486
|
-
|
|
405
|
+
token = advance
|
|
406
|
+
[day_of_month(kind.value, token), token]
|
|
487
407
|
end
|
|
488
408
|
|
|
489
|
-
def
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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
|
|
496
417
|
end
|
|
497
418
|
|
|
498
|
-
def
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
advance
|
|
503
|
-
return k.position
|
|
504
|
-
end
|
|
505
|
-
if k == TokenKind::LAST
|
|
506
|
-
advance
|
|
507
|
-
return OrdinalPosition::LAST
|
|
508
|
-
end
|
|
509
|
-
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)
|
|
510
423
|
end
|
|
511
424
|
|
|
512
|
-
def
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
times = parse_time_list
|
|
516
|
-
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
|
|
517
428
|
end
|
|
518
429
|
|
|
519
|
-
def
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
430
|
+
def parse_year_repeat(interval)
|
|
431
|
+
expect(TokenKind::ON, Expected::ON)
|
|
432
|
+
|
|
433
|
+
kind = peek_kind
|
|
434
|
+
target = if kind == TokenKind::THE
|
|
523
435
|
advance
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
if k.is_a?(TMonthName)
|
|
527
|
-
month = k.name
|
|
436
|
+
parse_year_target_after_the
|
|
437
|
+
elsif kind.is_a?(TMonthName)
|
|
528
438
|
advance
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
439
|
+
YearDateTarget.new(kind.name, parse_day_of(kind.name))
|
|
440
|
+
else
|
|
441
|
+
raise expected(Expected::YEAR_TARGET)
|
|
532
442
|
end
|
|
533
|
-
|
|
443
|
+
|
|
444
|
+
expect(TokenKind::AT, Expected::AT)
|
|
445
|
+
YearRepeat.new(interval, target, parse_time_list)
|
|
534
446
|
end
|
|
535
447
|
|
|
536
|
-
def
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
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)
|
|
551
462
|
else
|
|
552
|
-
raise
|
|
463
|
+
raise expected(Expected::YEAR_THE)
|
|
553
464
|
end
|
|
554
465
|
end
|
|
555
466
|
|
|
556
|
-
def
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
days = [k.name]
|
|
561
|
-
advance
|
|
562
|
-
|
|
563
|
-
while peek_kind == TokenKind::COMMA
|
|
467
|
+
def parse_year_last_target
|
|
468
|
+
kind = peek_kind
|
|
469
|
+
if kind == TokenKind::WEEKDAY_KW
|
|
564
470
|
advance
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
days << nk.name
|
|
471
|
+
YearLastWeekdayTarget.new(parse_of_month)
|
|
472
|
+
elsif kind.is_a?(TDayName)
|
|
569
473
|
advance
|
|
474
|
+
YearOrdinalWeekdayTarget.new(OrdinalPosition::LAST, kind.name, parse_of_month)
|
|
475
|
+
else
|
|
476
|
+
raise expected(Expected::YEAR_LAST)
|
|
570
477
|
end
|
|
571
|
-
days
|
|
572
478
|
end
|
|
573
479
|
|
|
574
|
-
def
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
advance
|
|
578
|
-
specs << parse_ordinal_day_spec
|
|
579
|
-
end
|
|
580
|
-
specs
|
|
480
|
+
def parse_of_month
|
|
481
|
+
expect(TokenKind::OF, Expected::OF)
|
|
482
|
+
parse_month_name
|
|
581
483
|
end
|
|
582
484
|
|
|
583
|
-
def
|
|
584
|
-
|
|
585
|
-
raise
|
|
485
|
+
def parse_month_name
|
|
486
|
+
kind = peek_kind
|
|
487
|
+
raise expected(Expected::MONTH_NAME) unless kind.is_a?(TMonthName)
|
|
586
488
|
|
|
587
|
-
start = k.value
|
|
588
|
-
span = current_span
|
|
589
|
-
raise error("invalid day number #{start} (must be 1-31)", span) if start < 1 || start > 31
|
|
590
489
|
advance
|
|
490
|
+
kind.name
|
|
491
|
+
end
|
|
591
492
|
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
end_day = nk.value
|
|
598
|
-
end_span = current_span
|
|
599
|
-
raise error("invalid day number #{end_day} (must be 1-31)", end_span) if end_day < 1 || end_day > 31
|
|
600
|
-
advance
|
|
601
|
-
raise error("invalid day range: #{start} to #{end_day} (start must be <= end)", current_span) if start > end_day
|
|
602
|
-
return DayRange.new(start, end_day)
|
|
603
|
-
end
|
|
604
|
-
|
|
605
|
-
SingleDay.new(start)
|
|
493
|
+
def parse_month_list
|
|
494
|
+
months = [parse_month_name]
|
|
495
|
+
months << parse_month_name while eat(TokenKind::COMMA)
|
|
496
|
+
months
|
|
606
497
|
end
|
|
607
498
|
|
|
608
|
-
def
|
|
609
|
-
|
|
499
|
+
def parse_on
|
|
500
|
+
date = parse_date
|
|
501
|
+
expect(TokenKind::AT, Expected::AT)
|
|
502
|
+
SingleDateExpr.new(date, parse_time_list)
|
|
503
|
+
end
|
|
610
504
|
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
505
|
+
def parse_day_target
|
|
506
|
+
case peek_kind
|
|
507
|
+
when TokenKind::DAY
|
|
614
508
|
advance
|
|
615
|
-
|
|
616
|
-
|
|
509
|
+
DayFilterEvery.new
|
|
510
|
+
when TokenKind::WEEKDAY_KW
|
|
617
511
|
advance
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
consume_keyword("'nearest'", TokenKind::NEAREST)
|
|
622
|
-
consume_keyword("'weekday'", TokenKind::WEEKDAY_KW)
|
|
623
|
-
consume_keyword("'to'", TokenKind::TO)
|
|
624
|
-
|
|
625
|
-
# Parse the day number (ordinal like 15th)
|
|
626
|
-
k = peek_kind
|
|
627
|
-
if k.is_a?(TOrdinalNumber)
|
|
628
|
-
day = k.value
|
|
629
|
-
if day < 1 || day > 31
|
|
630
|
-
raise error("invalid day number #{day} (must be 1-31)", current_span)
|
|
631
|
-
end
|
|
512
|
+
DayFilterWeekday.new
|
|
513
|
+
when TokenKind::WEEKEND_KW
|
|
632
514
|
advance
|
|
515
|
+
DayFilterWeekend.new
|
|
516
|
+
when TDayName
|
|
517
|
+
DayFilterDays.new(parse_day_list)
|
|
633
518
|
else
|
|
634
|
-
raise
|
|
519
|
+
raise expected(Expected::DAY_TARGET)
|
|
635
520
|
end
|
|
521
|
+
end
|
|
636
522
|
|
|
637
|
-
|
|
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
|
|
638
529
|
end
|
|
639
530
|
|
|
640
|
-
def
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
months << parse_month_name_token
|
|
645
|
-
end
|
|
646
|
-
months
|
|
531
|
+
def parse_day_list
|
|
532
|
+
days = [parse_day_name]
|
|
533
|
+
days << parse_day_name while eat(TokenKind::COMMA)
|
|
534
|
+
days
|
|
647
535
|
end
|
|
648
536
|
|
|
649
537
|
def parse_time_list
|
|
650
538
|
times = [parse_time]
|
|
651
|
-
|
|
652
|
-
advance
|
|
653
|
-
times << parse_time
|
|
654
|
-
end
|
|
539
|
+
times << parse_time while eat(TokenKind::COMMA)
|
|
655
540
|
times
|
|
656
541
|
end
|
|
657
542
|
|
|
658
543
|
def parse_time
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
if k.is_a?(TTime)
|
|
662
|
-
advance
|
|
663
|
-
return TimeOfDay.new(k.hour, k.minute)
|
|
664
|
-
end
|
|
665
|
-
raise error("expected time (HH:MM)", span)
|
|
666
|
-
end
|
|
667
|
-
end
|
|
668
|
-
|
|
669
|
-
def self.parse(input)
|
|
670
|
-
tokens = tokenize(input)
|
|
671
|
-
|
|
672
|
-
raise HronError.parse("empty expression", Span.new(0, 0), input) if tokens.empty?
|
|
673
|
-
|
|
674
|
-
parser = Parser.new(tokens, input)
|
|
675
|
-
schedule = parser.parse_expression
|
|
676
|
-
|
|
677
|
-
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)
|
|
678
546
|
|
|
679
|
-
|
|
547
|
+
advance
|
|
548
|
+
TimeOfDay.new(kind.hour, kind.minute)
|
|
549
|
+
end
|
|
680
550
|
end
|
|
681
551
|
end
|