hron 1.0.0 → 2.1.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.
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
- (@pos < @tokens.length) ? @tokens[@pos] : nil
94
+ @tokens[@pos]
19
95
  end
20
96
 
21
97
  def peek_kind
22
- tok = peek
23
- tok&.kind
98
+ peek&.kind
24
99
  end
25
100
 
26
101
  def advance
27
- tok = peek
28
- @pos += 1 if tok
29
- tok
102
+ token = @tokens[@pos]
103
+ @pos += 1
104
+ token
30
105
  end
31
106
 
32
- def current_span
33
- tok = peek
34
- return tok.span if tok
107
+ def previous
108
+ @tokens[@pos - 1]
109
+ end
35
110
 
36
- if @tokens.any?
37
- last = @tokens.last
38
- Span.new(last.span.end_pos, last.span.end_pos)
39
- else
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 error(message, span)
45
- HronError.parse(message, span, @input)
117
+ def expect(kind, what)
118
+ raise expected(what) unless eat(kind)
46
119
  end
47
120
 
48
- def error_at_end(message)
49
- if @tokens.any?
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 consume(expected, check_class)
59
- span = current_span
60
- tok = peek
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
- raise error_at_end("expected #{expected}")
129
+ def token_error(message, token)
130
+ error(message, token.span.start, token.span.end_pos)
68
131
  end
69
132
 
70
- def consume_keyword(expected, kind_symbol)
71
- span = current_span
72
- tok = peek
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
- raise error_at_end("expected #{expected}")
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
- span = current_span
86
- kind = peek_kind
87
-
88
- case kind
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 error("expected 'every' or 'on'", span)
147
+ raise expected(Expected::EVERY_OR_ON)
97
148
  end
98
-
99
- parse_trailing_clauses(expr)
100
149
  end
101
150
 
102
- def parse_trailing_clauses(expr)
103
- schedule = ScheduleData.new(expr: expr)
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
- schedule = ScheduleData.new(
122
- expr: schedule.expr,
123
- timezone: schedule.timezone,
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
- # starting
132
- if peek_kind == TokenKind::STARTING
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
- # 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
168
+ fields[:during] = parse_month_list if eat(TokenKind::DURING)
163
169
 
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)
170
+ if eat(TokenKind::IN)
171
+ raise expected(Expected::TIMEZONE) unless peek_kind.is_a?(TTimezone)
169
172
 
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
- )
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
- schedule
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
- while peek_kind == TokenKind::COMMA
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 validate_iso_date(date_str)
196
- Date.iso8601(date_str)
197
- rescue Date::Error
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 parse_exception
222
- k = peek_kind
223
- if k.is_a?(TIsoDate)
224
- validate_iso_date(k.date)
225
- advance
226
- return IsoException.new(k.date)
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
- day = parse_day_number("expected day number after month name in exception")
232
- validate_named_date(month, day)
233
- return NamedException.new(month, day)
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 parse_until_spec
239
- k = peek_kind
240
- if k.is_a?(TIsoDate)
241
- validate_iso_date(k.date)
242
- advance
243
- return IsoUntil.new(k.date)
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 parse_day_number(error_msg)
256
- k = peek_kind
257
- if k.is_a?(TNumber)
258
- val = k.value
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
- raise error_at_end("expected repeater") unless peek
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
- days = parse_day_list
296
- parse_day_repeat(1, DayFilterDays.new(days))
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::WEEKS
264
+ when TokenKind::YEAR
301
265
  advance
302
- parse_week_repeat(1)
266
+ parse_year_repeat(1)
303
267
  when TNumber
304
- parse_number_repeat
268
+ parse_number_repeat(kind.value)
305
269
  else
306
- raise error(
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
- consume_keyword("'day'", TokenKind::DAY) if days.is_a?(DayFilterEvery)
315
- consume_keyword("'at'", TokenKind::AT)
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
- span = current_span
322
- k = peek_kind
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
- nk = peek_kind
329
- case nk
283
+ kind = peek_kind
284
+ case kind
330
285
  when TokenKind::WEEKS
331
286
  advance
332
- parse_week_repeat(num)
287
+ parse_week_repeat(interval)
333
288
  when TIntervalUnit
334
- parse_interval_repeat(num)
289
+ advance
290
+ parse_interval_repeat(interval, kind.unit)
335
291
  when TokenKind::DAY
336
- parse_day_repeat(num, DayFilterEvery.new)
292
+ advance
293
+ parse_day_repeat(interval, DayFilterEvery.new)
337
294
  when TokenKind::MONTH
338
295
  advance
339
- parse_month_repeat(num)
296
+ parse_month_repeat(interval)
340
297
  when TokenKind::YEAR
341
298
  advance
342
- parse_year_repeat(num)
299
+ parse_year_repeat(interval)
343
300
  else
344
- raise error(
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
- k = peek_kind
353
- unit = k.unit
354
- advance
355
-
356
- consume_keyword("'from'", TokenKind::FROM)
357
- from_time = parse_time
358
- consume_keyword("'to'", TokenKind::TO)
359
- to_time = parse_time
360
-
361
- day_filter = nil
362
- if peek_kind == TokenKind::ON
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
- IntervalRepeat.new(interval, unit, from_time, to_time, day_filter)
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
- consume_keyword("'on'", TokenKind::ON)
324
+ expect(TokenKind::ON, Expected::ON)
372
325
  days = parse_day_list
373
- consume_keyword("'at'", TokenKind::AT)
374
- times = parse_time_list
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
- consume_keyword("'on'", TokenKind::ON)
380
- consume_keyword("'the'", TokenKind::THE)
331
+ expect(TokenKind::ON, Expected::ON)
332
+ expect(TokenKind::THE, Expected::THE)
381
333
 
382
- k = peek_kind
383
-
384
- if k == TokenKind::LAST
334
+ kind = peek_kind
335
+ target = if kind == TokenKind::LAST
385
336
  advance
386
- nk = peek_kind
387
- if nk == TokenKind::DAY
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
- target = OrdinalWeekdayTarget.new(ordinal, weekday)
408
- elsif k.is_a?(TOrdinalNumber)
409
- specs = parse_ordinal_day_list
410
- target = DaysTarget.new(specs)
411
- elsif k == TokenKind::NEXT || k == TokenKind::PREVIOUS || k == TokenKind::NEAREST
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 error("expected ordinal day (1st, 15th), 'last', ordinal (first..fifth), or '[next|previous] nearest' after 'the'", current_span)
346
+ raise expected(Expected::MONTH_TARGET)
415
347
  end
416
348
 
417
- consume_keyword("'at'", TokenKind::AT)
418
- times = parse_time_list
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 parse_year_repeat(interval)
423
- consume_keyword("'on'", TokenKind::ON)
424
-
425
- k = peek_kind
426
-
427
- if k == TokenKind::THE
428
- advance
429
- target = parse_year_target_after_the
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 error("expected month name or 'the' after 'every year on'", current_span)
362
+ raise expected(Expected::MONTH_LAST)
438
363
  end
364
+ advance
365
+ target
366
+ end
439
367
 
440
- consume_keyword("'at'", TokenKind::AT)
441
- times = parse_time_list
442
- YearRepeat.new(interval, target, times)
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 parse_year_target_after_the
446
- k = peek_kind
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
- if k == TokenKind::LAST
449
- advance
450
- nk = peek_kind
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
- if k.is_a?(TOrdinal)
468
- ordinal = parse_ordinal_position
469
- nk = peek_kind
470
- if nk.is_a?(TDayName)
471
- weekday = nk.name
472
- advance
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
- if k.is_a?(TOrdinalNumber)
481
- day = k.value
482
- advance
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
- raise error("expected ordinal, day number, or 'last' after 'the' in yearly expression", current_span)
405
+ token = advance
406
+ [day_of_month(kind.value, token), token]
490
407
  end
491
408
 
492
- def parse_month_name_token
493
- k = peek_kind
494
- if k.is_a?(TMonthName)
495
- advance
496
- return k.name
497
- end
498
- raise error("expected month name", current_span)
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 parse_ordinal_position
502
- span = current_span
503
- k = peek_kind
504
- if k.is_a?(TOrdinal)
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 parse_on
516
- date = parse_date_target
517
- consume_keyword("'at'", TokenKind::AT)
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 parse_date_target
523
- k = peek_kind
524
- if k.is_a?(TIsoDate)
525
- validate_iso_date(k.date)
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
- return IsoDate.new(k.date)
528
- end
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
- day = parse_day_number("expected day number after month name")
533
- validate_named_date(month, day)
534
- return NamedDate.new(month, day)
439
+ YearDateTarget.new(kind.name, parse_day_of(kind.name))
440
+ else
441
+ raise expected(Expected::YEAR_TARGET)
535
442
  end
536
- raise error("expected date (ISO date or month name)", current_span)
443
+
444
+ expect(TokenKind::AT, Expected::AT)
445
+ YearRepeat.new(interval, target, parse_time_list)
537
446
  end
538
447
 
539
- def parse_day_target
540
- k = peek_kind
541
- case k
542
- when TokenKind::DAY
543
- advance
544
- DayFilterEvery.new
545
- when TokenKind::WEEKDAY_KW
546
- advance
547
- DayFilterWeekday.new
548
- when TokenKind::WEEKEND_KW
549
- advance
550
- DayFilterWeekend.new
551
- when TDayName
552
- days = parse_day_list
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 error("expected 'day', 'weekday', 'weekend', or day name", current_span)
463
+ raise expected(Expected::YEAR_THE)
556
464
  end
557
465
  end
558
466
 
559
- def parse_day_list
560
- k = peek_kind
561
- raise error("expected day name", current_span) unless k.is_a?(TDayName)
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
- nk = peek_kind
569
- raise error("expected day name after ','", current_span) unless nk.is_a?(TDayName)
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 parse_ordinal_day_list
578
- specs = [parse_ordinal_day_spec]
579
- while peek_kind == TokenKind::COMMA
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 parse_ordinal_day_spec
587
- k = peek_kind
588
- raise error("expected ordinal day number", current_span) unless k.is_a?(TOrdinalNumber)
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
- if peek_kind == TokenKind::TO
596
- advance
597
- nk = peek_kind
598
- raise error("expected ordinal day number after 'to'", current_span) unless nk.is_a?(TOrdinalNumber)
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 parse_nearest_weekday_target
612
- k = peek_kind
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
- # Optional direction: "next" or "previous"
615
- direction = nil
616
- if k == TokenKind::NEXT
505
+ def parse_day_target
506
+ case peek_kind
507
+ when TokenKind::DAY
617
508
  advance
618
- direction = NearestDirection::NEXT
619
- elsif k == TokenKind::PREVIOUS
509
+ DayFilterEvery.new
510
+ when TokenKind::WEEKDAY_KW
620
511
  advance
621
- direction = NearestDirection::PREVIOUS
622
- end
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 error("expected ordinal day number after 'to'", current_span)
519
+ raise expected(Expected::DAY_TARGET)
638
520
  end
521
+ end
639
522
 
640
- NearestWeekdayTarget.new(day, direction)
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 parse_month_list
644
- months = [parse_month_name_token]
645
- while peek_kind == TokenKind::COMMA
646
- advance
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
- while peek_kind == TokenKind::COMMA
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
- span = current_span
663
- k = peek_kind
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
- schedule
547
+ advance
548
+ TimeOfDay.new(kind.hour, kind.minute)
549
+ end
683
550
  end
684
551
  end