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.
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hron
4
+ VERSION = "0.5.1"
5
+ end
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