hron 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/README.md +98 -0
- data/Rakefile +13 -0
- data/hron.gemspec +39 -0
- data/lib/hron/ast.rb +235 -0
- data/lib/hron/cron.rb +250 -0
- data/lib/hron/display.rb +166 -0
- data/lib/hron/error.rb +64 -0
- data/lib/hron/evaluator.rb +725 -0
- data/lib/hron/lexer.rb +253 -0
- data/lib/hron/parser.rb +617 -0
- data/lib/hron/schedule.rb +75 -0
- data/lib/hron/version.rb +5 -0
- data/lib/hron.rb +30 -0
- metadata +116 -0
data/lib/hron/parser.rb
ADDED
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "ast"
|
|
4
|
+
require_relative "error"
|
|
5
|
+
require_relative "lexer"
|
|
6
|
+
|
|
7
|
+
module Hron
|
|
8
|
+
# Parser for hron expressions
|
|
9
|
+
class Parser
|
|
10
|
+
def initialize(tokens, input)
|
|
11
|
+
@tokens = tokens
|
|
12
|
+
@pos = 0
|
|
13
|
+
@input = input
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def peek
|
|
17
|
+
(@pos < @tokens.length) ? @tokens[@pos] : nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def peek_kind
|
|
21
|
+
tok = peek
|
|
22
|
+
tok&.kind
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def advance
|
|
26
|
+
tok = peek
|
|
27
|
+
@pos += 1 if tok
|
|
28
|
+
tok
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def current_span
|
|
32
|
+
tok = peek
|
|
33
|
+
return tok.span if tok
|
|
34
|
+
|
|
35
|
+
if @tokens.any?
|
|
36
|
+
last = @tokens.last
|
|
37
|
+
Span.new(last.span.end_pos, last.span.end_pos)
|
|
38
|
+
else
|
|
39
|
+
Span.new(0, 0)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def error(message, span)
|
|
44
|
+
HronError.parse(message, span, @input)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def error_at_end(message)
|
|
48
|
+
if @tokens.any?
|
|
49
|
+
end_pos = @tokens.last.span.end_pos
|
|
50
|
+
span = Span.new(end_pos, end_pos)
|
|
51
|
+
else
|
|
52
|
+
span = Span.new(0, 0)
|
|
53
|
+
end
|
|
54
|
+
HronError.parse(message, span, @input)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def consume(expected, check_class)
|
|
58
|
+
span = current_span
|
|
59
|
+
tok = peek
|
|
60
|
+
if tok && tok.kind.is_a?(check_class)
|
|
61
|
+
@pos += 1
|
|
62
|
+
return tok
|
|
63
|
+
end
|
|
64
|
+
raise error("expected #{expected}, got #{tok.kind.class.name.split("::").last}", span) if tok
|
|
65
|
+
|
|
66
|
+
raise error_at_end("expected #{expected}")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def consume_keyword(expected, kind_symbol)
|
|
70
|
+
span = current_span
|
|
71
|
+
tok = peek
|
|
72
|
+
if tok && tok.kind == kind_symbol
|
|
73
|
+
@pos += 1
|
|
74
|
+
return tok
|
|
75
|
+
end
|
|
76
|
+
raise error("expected #{expected}", span) if tok
|
|
77
|
+
|
|
78
|
+
raise error_at_end("expected #{expected}")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# --- Grammar productions ---
|
|
82
|
+
|
|
83
|
+
def parse_expression
|
|
84
|
+
span = current_span
|
|
85
|
+
kind = peek_kind
|
|
86
|
+
|
|
87
|
+
case kind
|
|
88
|
+
when TokenKind::EVERY
|
|
89
|
+
advance
|
|
90
|
+
expr = parse_every
|
|
91
|
+
when TokenKind::ON
|
|
92
|
+
advance
|
|
93
|
+
expr = parse_on
|
|
94
|
+
when TOrdinal, TokenKind::LAST
|
|
95
|
+
expr = parse_ordinal_repeat
|
|
96
|
+
else
|
|
97
|
+
raise error("expected 'every', 'on', or an ordinal (first, second, ...)", span)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
parse_trailing_clauses(expr)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def parse_trailing_clauses(expr)
|
|
104
|
+
schedule = ScheduleData.new(expr: expr)
|
|
105
|
+
|
|
106
|
+
# except
|
|
107
|
+
if peek_kind == TokenKind::EXCEPT
|
|
108
|
+
advance
|
|
109
|
+
schedule = ScheduleData.new(
|
|
110
|
+
expr: schedule.expr,
|
|
111
|
+
timezone: schedule.timezone,
|
|
112
|
+
except: parse_exception_list,
|
|
113
|
+
until: schedule.until,
|
|
114
|
+
anchor: schedule.anchor,
|
|
115
|
+
during: schedule.during
|
|
116
|
+
)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# until
|
|
120
|
+
if peek_kind == TokenKind::UNTIL
|
|
121
|
+
advance
|
|
122
|
+
schedule = ScheduleData.new(
|
|
123
|
+
expr: schedule.expr,
|
|
124
|
+
timezone: schedule.timezone,
|
|
125
|
+
except: schedule.except,
|
|
126
|
+
until: parse_until_spec,
|
|
127
|
+
anchor: schedule.anchor,
|
|
128
|
+
during: schedule.during
|
|
129
|
+
)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# starting
|
|
133
|
+
if peek_kind == TokenKind::STARTING
|
|
134
|
+
advance
|
|
135
|
+
k = peek_kind
|
|
136
|
+
raise error("expected ISO date (YYYY-MM-DD) after 'starting'", current_span) unless k.is_a?(TIsoDate)
|
|
137
|
+
|
|
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
|
+
)
|
|
148
|
+
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# during
|
|
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
|
|
163
|
+
|
|
164
|
+
# in <timezone>
|
|
165
|
+
if peek_kind == TokenKind::IN
|
|
166
|
+
advance
|
|
167
|
+
k = peek_kind
|
|
168
|
+
raise error("expected timezone after 'in'", current_span) unless k.is_a?(TTimezone)
|
|
169
|
+
|
|
170
|
+
tz = k.tz
|
|
171
|
+
advance
|
|
172
|
+
schedule = ScheduleData.new(
|
|
173
|
+
expr: schedule.expr,
|
|
174
|
+
timezone: tz,
|
|
175
|
+
except: schedule.except,
|
|
176
|
+
until: schedule.until,
|
|
177
|
+
anchor: schedule.anchor,
|
|
178
|
+
during: schedule.during
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
schedule
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def parse_exception_list
|
|
187
|
+
exceptions = [parse_exception]
|
|
188
|
+
while peek_kind == TokenKind::COMMA
|
|
189
|
+
advance
|
|
190
|
+
exceptions << parse_exception
|
|
191
|
+
end
|
|
192
|
+
exceptions
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def parse_exception
|
|
196
|
+
k = peek_kind
|
|
197
|
+
if k.is_a?(TIsoDate)
|
|
198
|
+
advance
|
|
199
|
+
return IsoException.new(k.date)
|
|
200
|
+
end
|
|
201
|
+
if k.is_a?(TMonthName)
|
|
202
|
+
month = k.name
|
|
203
|
+
advance
|
|
204
|
+
day = parse_day_number("expected day number after month name in exception")
|
|
205
|
+
return NamedException.new(month, day)
|
|
206
|
+
end
|
|
207
|
+
raise error("expected ISO date or month-day in exception", current_span)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def parse_until_spec
|
|
211
|
+
k = peek_kind
|
|
212
|
+
if k.is_a?(TIsoDate)
|
|
213
|
+
advance
|
|
214
|
+
return IsoUntil.new(k.date)
|
|
215
|
+
end
|
|
216
|
+
if k.is_a?(TMonthName)
|
|
217
|
+
month = k.name
|
|
218
|
+
advance
|
|
219
|
+
day = parse_day_number("expected day number after month name in until")
|
|
220
|
+
return NamedUntil.new(month, day)
|
|
221
|
+
end
|
|
222
|
+
raise error("expected ISO date or month-day after 'until'", current_span)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def parse_day_number(error_msg)
|
|
226
|
+
k = peek_kind
|
|
227
|
+
if k.is_a?(TNumber)
|
|
228
|
+
advance
|
|
229
|
+
return k.value
|
|
230
|
+
end
|
|
231
|
+
if k.is_a?(TOrdinalNumber)
|
|
232
|
+
advance
|
|
233
|
+
return k.value
|
|
234
|
+
end
|
|
235
|
+
raise error(error_msg, current_span)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# After "every": dispatch
|
|
239
|
+
def parse_every
|
|
240
|
+
raise error_at_end("expected repeater") unless peek
|
|
241
|
+
|
|
242
|
+
k = peek_kind
|
|
243
|
+
|
|
244
|
+
case k
|
|
245
|
+
when TokenKind::YEAR
|
|
246
|
+
advance
|
|
247
|
+
parse_year_repeat(1)
|
|
248
|
+
when TokenKind::DAY
|
|
249
|
+
parse_day_repeat(1, DayFilterEvery.new)
|
|
250
|
+
when TokenKind::WEEKDAY_KW
|
|
251
|
+
advance
|
|
252
|
+
parse_day_repeat(1, DayFilterWeekday.new)
|
|
253
|
+
when TokenKind::WEEKEND_KW
|
|
254
|
+
advance
|
|
255
|
+
parse_day_repeat(1, DayFilterWeekend.new)
|
|
256
|
+
when TDayName
|
|
257
|
+
days = parse_day_list
|
|
258
|
+
parse_day_repeat(1, DayFilterDays.new(days))
|
|
259
|
+
when TokenKind::MONTH
|
|
260
|
+
advance
|
|
261
|
+
parse_month_repeat(1)
|
|
262
|
+
when TNumber
|
|
263
|
+
parse_number_repeat
|
|
264
|
+
else
|
|
265
|
+
raise error(
|
|
266
|
+
"expected day, weekday, weekend, year, day name, month, or number after 'every'",
|
|
267
|
+
current_span
|
|
268
|
+
)
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def parse_day_repeat(interval, days)
|
|
273
|
+
consume_keyword("'day'", TokenKind::DAY) if days.is_a?(DayFilterEvery)
|
|
274
|
+
consume_keyword("'at'", TokenKind::AT)
|
|
275
|
+
times = parse_time_list
|
|
276
|
+
DayRepeat.new(interval, days, times)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def parse_number_repeat
|
|
280
|
+
span = current_span
|
|
281
|
+
k = peek_kind
|
|
282
|
+
num = k.value
|
|
283
|
+
raise error("interval must be at least 1", span) if num.zero?
|
|
284
|
+
|
|
285
|
+
advance
|
|
286
|
+
|
|
287
|
+
nk = peek_kind
|
|
288
|
+
case nk
|
|
289
|
+
when TokenKind::WEEKS
|
|
290
|
+
advance
|
|
291
|
+
parse_week_repeat(num)
|
|
292
|
+
when TIntervalUnit
|
|
293
|
+
parse_interval_repeat(num)
|
|
294
|
+
when TokenKind::DAY
|
|
295
|
+
parse_day_repeat(num, DayFilterEvery.new)
|
|
296
|
+
when TokenKind::MONTH
|
|
297
|
+
advance
|
|
298
|
+
parse_month_repeat(num)
|
|
299
|
+
when TokenKind::YEAR
|
|
300
|
+
advance
|
|
301
|
+
parse_year_repeat(num)
|
|
302
|
+
else
|
|
303
|
+
raise error(
|
|
304
|
+
"expected 'weeks', 'min', 'minutes', 'hour', 'hours', 'day(s)', 'month(s)', or 'year(s)' after number",
|
|
305
|
+
current_span
|
|
306
|
+
)
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def parse_interval_repeat(interval)
|
|
311
|
+
k = peek_kind
|
|
312
|
+
unit = k.unit
|
|
313
|
+
advance
|
|
314
|
+
|
|
315
|
+
consume_keyword("'from'", TokenKind::FROM)
|
|
316
|
+
from_time = parse_time
|
|
317
|
+
consume_keyword("'to'", TokenKind::TO)
|
|
318
|
+
to_time = parse_time
|
|
319
|
+
|
|
320
|
+
day_filter = nil
|
|
321
|
+
if peek_kind == TokenKind::ON
|
|
322
|
+
advance
|
|
323
|
+
day_filter = parse_day_target
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
IntervalRepeat.new(interval, unit, from_time, to_time, day_filter)
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def parse_week_repeat(interval)
|
|
330
|
+
consume_keyword("'on'", TokenKind::ON)
|
|
331
|
+
days = parse_day_list
|
|
332
|
+
consume_keyword("'at'", TokenKind::AT)
|
|
333
|
+
times = parse_time_list
|
|
334
|
+
WeekRepeat.new(interval, days, times)
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def parse_month_repeat(interval)
|
|
338
|
+
consume_keyword("'on'", TokenKind::ON)
|
|
339
|
+
consume_keyword("'the'", TokenKind::THE)
|
|
340
|
+
|
|
341
|
+
k = peek_kind
|
|
342
|
+
|
|
343
|
+
if k == TokenKind::LAST
|
|
344
|
+
advance
|
|
345
|
+
nk = peek_kind
|
|
346
|
+
if nk == TokenKind::DAY
|
|
347
|
+
advance
|
|
348
|
+
target = LastDayTarget.new
|
|
349
|
+
elsif nk == TokenKind::WEEKDAY_KW
|
|
350
|
+
advance
|
|
351
|
+
target = LastWeekdayTarget.new
|
|
352
|
+
else
|
|
353
|
+
raise error("expected 'day' or 'weekday' after 'last'", current_span)
|
|
354
|
+
end
|
|
355
|
+
elsif k.is_a?(TOrdinalNumber)
|
|
356
|
+
specs = parse_ordinal_day_list
|
|
357
|
+
target = DaysTarget.new(specs)
|
|
358
|
+
else
|
|
359
|
+
raise error("expected ordinal day (1st, 15th) or 'last' after 'the'", current_span)
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
consume_keyword("'at'", TokenKind::AT)
|
|
363
|
+
times = parse_time_list
|
|
364
|
+
MonthRepeat.new(interval, target, times)
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def parse_ordinal_repeat
|
|
368
|
+
ordinal = parse_ordinal_position
|
|
369
|
+
|
|
370
|
+
k = peek_kind
|
|
371
|
+
raise error("expected day name after ordinal", current_span) unless k.is_a?(TDayName)
|
|
372
|
+
|
|
373
|
+
day = k.name
|
|
374
|
+
advance
|
|
375
|
+
|
|
376
|
+
consume_keyword("'of'", TokenKind::OF)
|
|
377
|
+
consume_keyword("'every'", TokenKind::EVERY)
|
|
378
|
+
|
|
379
|
+
# "of every [N] month(s) at ..."
|
|
380
|
+
interval = 1
|
|
381
|
+
nk = peek_kind
|
|
382
|
+
if nk.is_a?(TNumber)
|
|
383
|
+
interval = nk.value
|
|
384
|
+
raise error("interval must be at least 1", current_span) if interval.zero?
|
|
385
|
+
|
|
386
|
+
advance
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
consume_keyword("'month'", TokenKind::MONTH)
|
|
390
|
+
consume_keyword("'at'", TokenKind::AT)
|
|
391
|
+
times = parse_time_list
|
|
392
|
+
|
|
393
|
+
OrdinalRepeat.new(interval, ordinal, day, times)
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def parse_year_repeat(interval)
|
|
397
|
+
consume_keyword("'on'", TokenKind::ON)
|
|
398
|
+
|
|
399
|
+
k = peek_kind
|
|
400
|
+
|
|
401
|
+
if k == TokenKind::THE
|
|
402
|
+
advance
|
|
403
|
+
target = parse_year_target_after_the
|
|
404
|
+
elsif k.is_a?(TMonthName)
|
|
405
|
+
month = k.name
|
|
406
|
+
advance
|
|
407
|
+
day = parse_day_number("expected day number after month name")
|
|
408
|
+
target = YearDateTarget.new(month, day)
|
|
409
|
+
else
|
|
410
|
+
raise error("expected month name or 'the' after 'every year on'", current_span)
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
consume_keyword("'at'", TokenKind::AT)
|
|
414
|
+
times = parse_time_list
|
|
415
|
+
YearRepeat.new(interval, target, times)
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def parse_year_target_after_the
|
|
419
|
+
k = peek_kind
|
|
420
|
+
|
|
421
|
+
if k == TokenKind::LAST
|
|
422
|
+
advance
|
|
423
|
+
nk = peek_kind
|
|
424
|
+
if nk == TokenKind::WEEKDAY_KW
|
|
425
|
+
advance
|
|
426
|
+
consume_keyword("'of'", TokenKind::OF)
|
|
427
|
+
month = parse_month_name_token
|
|
428
|
+
return YearLastWeekdayTarget.new(month)
|
|
429
|
+
end
|
|
430
|
+
if nk.is_a?(TDayName)
|
|
431
|
+
weekday = nk.name
|
|
432
|
+
advance
|
|
433
|
+
consume_keyword("'of'", TokenKind::OF)
|
|
434
|
+
month = parse_month_name_token
|
|
435
|
+
return YearOrdinalWeekdayTarget.new(OrdinalPosition::LAST, weekday, month)
|
|
436
|
+
end
|
|
437
|
+
raise error("expected 'weekday' or day name after 'last' in yearly expression", current_span)
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
if k.is_a?(TOrdinal)
|
|
441
|
+
ordinal = parse_ordinal_position
|
|
442
|
+
nk = peek_kind
|
|
443
|
+
if nk.is_a?(TDayName)
|
|
444
|
+
weekday = nk.name
|
|
445
|
+
advance
|
|
446
|
+
consume_keyword("'of'", TokenKind::OF)
|
|
447
|
+
month = parse_month_name_token
|
|
448
|
+
return YearOrdinalWeekdayTarget.new(ordinal, weekday, month)
|
|
449
|
+
end
|
|
450
|
+
raise error("expected day name after ordinal in yearly expression", current_span)
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
if k.is_a?(TOrdinalNumber)
|
|
454
|
+
day = k.value
|
|
455
|
+
advance
|
|
456
|
+
consume_keyword("'of'", TokenKind::OF)
|
|
457
|
+
month = parse_month_name_token
|
|
458
|
+
return YearDayOfMonthTarget.new(day, month)
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
raise error("expected ordinal, day number, or 'last' after 'the' in yearly expression", current_span)
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
def parse_month_name_token
|
|
465
|
+
k = peek_kind
|
|
466
|
+
if k.is_a?(TMonthName)
|
|
467
|
+
advance
|
|
468
|
+
return k.name
|
|
469
|
+
end
|
|
470
|
+
raise error("expected month name", current_span)
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
def parse_ordinal_position
|
|
474
|
+
span = current_span
|
|
475
|
+
k = peek_kind
|
|
476
|
+
if k.is_a?(TOrdinal)
|
|
477
|
+
advance
|
|
478
|
+
return k.position
|
|
479
|
+
end
|
|
480
|
+
if k == TokenKind::LAST
|
|
481
|
+
advance
|
|
482
|
+
return OrdinalPosition::LAST
|
|
483
|
+
end
|
|
484
|
+
raise error("expected ordinal (first, second, third, fourth, fifth, last)", span)
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
def parse_on
|
|
488
|
+
date = parse_date_target
|
|
489
|
+
consume_keyword("'at'", TokenKind::AT)
|
|
490
|
+
times = parse_time_list
|
|
491
|
+
SingleDateExpr.new(date, times)
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
def parse_date_target
|
|
495
|
+
k = peek_kind
|
|
496
|
+
if k.is_a?(TIsoDate)
|
|
497
|
+
advance
|
|
498
|
+
return IsoDate.new(k.date)
|
|
499
|
+
end
|
|
500
|
+
if k.is_a?(TMonthName)
|
|
501
|
+
month = k.name
|
|
502
|
+
advance
|
|
503
|
+
day = parse_day_number("expected day number after month name")
|
|
504
|
+
return NamedDate.new(month, day)
|
|
505
|
+
end
|
|
506
|
+
raise error("expected date (ISO date or month name)", current_span)
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
def parse_day_target
|
|
510
|
+
k = peek_kind
|
|
511
|
+
case k
|
|
512
|
+
when TokenKind::DAY
|
|
513
|
+
advance
|
|
514
|
+
DayFilterEvery.new
|
|
515
|
+
when TokenKind::WEEKDAY_KW
|
|
516
|
+
advance
|
|
517
|
+
DayFilterWeekday.new
|
|
518
|
+
when TokenKind::WEEKEND_KW
|
|
519
|
+
advance
|
|
520
|
+
DayFilterWeekend.new
|
|
521
|
+
when TDayName
|
|
522
|
+
days = parse_day_list
|
|
523
|
+
DayFilterDays.new(days)
|
|
524
|
+
else
|
|
525
|
+
raise error("expected 'day', 'weekday', 'weekend', or day name", current_span)
|
|
526
|
+
end
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
def parse_day_list
|
|
530
|
+
k = peek_kind
|
|
531
|
+
raise error("expected day name", current_span) unless k.is_a?(TDayName)
|
|
532
|
+
|
|
533
|
+
days = [k.name]
|
|
534
|
+
advance
|
|
535
|
+
|
|
536
|
+
while peek_kind == TokenKind::COMMA
|
|
537
|
+
advance
|
|
538
|
+
nk = peek_kind
|
|
539
|
+
raise error("expected day name after ','", current_span) unless nk.is_a?(TDayName)
|
|
540
|
+
|
|
541
|
+
days << nk.name
|
|
542
|
+
advance
|
|
543
|
+
end
|
|
544
|
+
days
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
def parse_ordinal_day_list
|
|
548
|
+
specs = [parse_ordinal_day_spec]
|
|
549
|
+
while peek_kind == TokenKind::COMMA
|
|
550
|
+
advance
|
|
551
|
+
specs << parse_ordinal_day_spec
|
|
552
|
+
end
|
|
553
|
+
specs
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
def parse_ordinal_day_spec
|
|
557
|
+
k = peek_kind
|
|
558
|
+
raise error("expected ordinal day number", current_span) unless k.is_a?(TOrdinalNumber)
|
|
559
|
+
|
|
560
|
+
start = k.value
|
|
561
|
+
advance
|
|
562
|
+
|
|
563
|
+
if peek_kind == TokenKind::TO
|
|
564
|
+
advance
|
|
565
|
+
nk = peek_kind
|
|
566
|
+
raise error("expected ordinal day number after 'to'", current_span) unless nk.is_a?(TOrdinalNumber)
|
|
567
|
+
|
|
568
|
+
end_day = nk.value
|
|
569
|
+
advance
|
|
570
|
+
return DayRange.new(start, end_day)
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
SingleDay.new(start)
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
def parse_month_list
|
|
577
|
+
months = [parse_month_name_token]
|
|
578
|
+
while peek_kind == TokenKind::COMMA
|
|
579
|
+
advance
|
|
580
|
+
months << parse_month_name_token
|
|
581
|
+
end
|
|
582
|
+
months
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
def parse_time_list
|
|
586
|
+
times = [parse_time]
|
|
587
|
+
while peek_kind == TokenKind::COMMA
|
|
588
|
+
advance
|
|
589
|
+
times << parse_time
|
|
590
|
+
end
|
|
591
|
+
times
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
def parse_time
|
|
595
|
+
span = current_span
|
|
596
|
+
k = peek_kind
|
|
597
|
+
if k.is_a?(TTime)
|
|
598
|
+
advance
|
|
599
|
+
return TimeOfDay.new(k.hour, k.minute)
|
|
600
|
+
end
|
|
601
|
+
raise error("expected time (HH:MM)", span)
|
|
602
|
+
end
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
def self.parse(input)
|
|
606
|
+
tokens = tokenize(input)
|
|
607
|
+
|
|
608
|
+
raise HronError.parse("empty expression", Span.new(0, 0), input) if tokens.empty?
|
|
609
|
+
|
|
610
|
+
parser = Parser.new(tokens, input)
|
|
611
|
+
schedule = parser.parse_expression
|
|
612
|
+
|
|
613
|
+
raise HronError.parse("unexpected tokens after expression", parser.current_span, input) if parser.peek
|
|
614
|
+
|
|
615
|
+
schedule
|
|
616
|
+
end
|
|
617
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "parser"
|
|
4
|
+
require_relative "evaluator"
|
|
5
|
+
require_relative "display"
|
|
6
|
+
require_relative "cron"
|
|
7
|
+
|
|
8
|
+
module Hron
|
|
9
|
+
# Main Schedule class - the primary public API for hron
|
|
10
|
+
class Schedule
|
|
11
|
+
attr_reader :data
|
|
12
|
+
|
|
13
|
+
def initialize(data)
|
|
14
|
+
@data = data
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Parse a hron expression and return a Schedule
|
|
18
|
+
def self.parse(input)
|
|
19
|
+
new(Hron.parse(input))
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Parse a cron expression and return a Schedule
|
|
23
|
+
def self.from_cron(cron_expr)
|
|
24
|
+
new(Cron.from_cron(cron_expr))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Validate a hron expression without raising an error
|
|
28
|
+
def self.validate(input)
|
|
29
|
+
Hron.parse(input)
|
|
30
|
+
true
|
|
31
|
+
rescue HronError
|
|
32
|
+
false
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Get the next occurrence from the given time
|
|
36
|
+
def next_from(now)
|
|
37
|
+
Evaluator.next_from(@data, now)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Get the next N occurrences from the given time
|
|
41
|
+
def next_n_from(now, n)
|
|
42
|
+
Evaluator.next_n_from(@data, now, n)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Check if the schedule matches the given datetime
|
|
46
|
+
def matches(dt)
|
|
47
|
+
Evaluator.matches(@data, dt)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Convert to 5-field cron expression
|
|
51
|
+
def to_cron
|
|
52
|
+
Cron.to_cron(@data)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Get the canonical string representation
|
|
56
|
+
def to_s
|
|
57
|
+
Display.display(@data)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Get inspect representation
|
|
61
|
+
def inspect
|
|
62
|
+
"Schedule(\"#{self}\")"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Get the timezone
|
|
66
|
+
def timezone
|
|
67
|
+
@data.timezone
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Get the schedule expression
|
|
71
|
+
def expression
|
|
72
|
+
@data.expr
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
data/lib/hron/version.rb
ADDED
data/lib/hron.rb
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "hron/version"
|
|
4
|
+
require_relative "hron/error"
|
|
5
|
+
require_relative "hron/ast"
|
|
6
|
+
require_relative "hron/lexer"
|
|
7
|
+
require_relative "hron/parser"
|
|
8
|
+
require_relative "hron/evaluator"
|
|
9
|
+
require_relative "hron/display"
|
|
10
|
+
require_relative "hron/cron"
|
|
11
|
+
require_relative "hron/schedule"
|
|
12
|
+
|
|
13
|
+
module Hron
|
|
14
|
+
class << self
|
|
15
|
+
# Parse a hron expression and return a Schedule
|
|
16
|
+
def parse_schedule(input)
|
|
17
|
+
Schedule.parse(input)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Validate a hron expression without raising an error
|
|
21
|
+
def validate(input)
|
|
22
|
+
Schedule.validate(input)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Parse from a cron expression
|
|
26
|
+
def from_cron(cron_expr)
|
|
27
|
+
Schedule.from_cron(cron_expr)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|