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.
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)
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
- 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,390 +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)
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 error(
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
- consume_keyword("'day'", TokenKind::DAY) if days.is_a?(DayFilterEvery)
312
- consume_keyword("'at'", TokenKind::AT)
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
- span = current_span
319
- k = peek_kind
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
- advance
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(num)
287
+ parse_week_repeat(interval)
330
288
  when TIntervalUnit
331
- parse_interval_repeat(num)
289
+ advance
290
+ parse_interval_repeat(interval, kind.unit)
332
291
  when TokenKind::DAY
333
- parse_day_repeat(num, DayFilterEvery.new)
292
+ advance
293
+ parse_day_repeat(interval, DayFilterEvery.new)
334
294
  when TokenKind::MONTH
335
295
  advance
336
- parse_month_repeat(num)
296
+ parse_month_repeat(interval)
337
297
  when TokenKind::YEAR
338
298
  advance
339
- parse_year_repeat(num)
299
+ parse_year_repeat(interval)
340
300
  else
341
- raise error(
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
- k = peek_kind
350
- unit = k.unit
351
- advance
352
-
353
- consume_keyword("'from'", TokenKind::FROM)
354
- from_time = parse_time
355
- consume_keyword("'to'", TokenKind::TO)
356
- to_time = parse_time
357
-
358
- day_filter = nil
359
- if peek_kind == TokenKind::ON
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
- 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)
365
321
  end
366
322
 
367
323
  def parse_week_repeat(interval)
368
- consume_keyword("'on'", TokenKind::ON)
324
+ expect(TokenKind::ON, Expected::ON)
369
325
  days = parse_day_list
370
- consume_keyword("'at'", TokenKind::AT)
371
- times = parse_time_list
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
- consume_keyword("'on'", TokenKind::ON)
377
- consume_keyword("'the'", TokenKind::THE)
378
-
379
- k = peek_kind
331
+ expect(TokenKind::ON, Expected::ON)
332
+ expect(TokenKind::THE, Expected::THE)
380
333
 
381
- if k == TokenKind::LAST
334
+ kind = peek_kind
335
+ target = if kind == TokenKind::LAST
382
336
  advance
383
- nk = peek_kind
384
- if nk == TokenKind::DAY
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
- target = OrdinalWeekdayTarget.new(ordinal, weekday)
405
- elsif k.is_a?(TOrdinalNumber)
406
- specs = parse_ordinal_day_list
407
- target = DaysTarget.new(specs)
408
- elsif k == TokenKind::NEXT || k == TokenKind::PREVIOUS || k == TokenKind::NEAREST
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 error("expected ordinal day (1st, 15th), 'last', ordinal (first..fifth), or '[next|previous] nearest' after 'the'", current_span)
346
+ raise expected(Expected::MONTH_TARGET)
412
347
  end
413
348
 
414
- consume_keyword("'at'", TokenKind::AT)
415
- times = parse_time_list
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 parse_year_repeat(interval)
420
- consume_keyword("'on'", TokenKind::ON)
421
-
422
- k = peek_kind
423
-
424
- if k == TokenKind::THE
425
- advance
426
- target = parse_year_target_after_the
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 error("expected month name or 'the' after 'every year on'", current_span)
362
+ raise expected(Expected::MONTH_LAST)
435
363
  end
364
+ advance
365
+ target
366
+ end
436
367
 
437
- consume_keyword("'at'", TokenKind::AT)
438
- times = parse_time_list
439
- 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)
440
379
  end
441
380
 
442
- def parse_year_target_after_the
443
- 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
444
386
 
445
- if k == TokenKind::LAST
446
- advance
447
- nk = peek_kind
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
- if k.is_a?(TOrdinal)
465
- ordinal = parse_ordinal_position
466
- nk = peek_kind
467
- if nk.is_a?(TDayName)
468
- weekday = nk.name
469
- advance
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
- if k.is_a?(TOrdinalNumber)
478
- day = k.value
479
- advance
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
- 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]
487
407
  end
488
408
 
489
- def parse_month_name_token
490
- k = peek_kind
491
- if k.is_a?(TMonthName)
492
- advance
493
- return k.name
494
- end
495
- 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
496
417
  end
497
418
 
498
- def parse_ordinal_position
499
- span = current_span
500
- k = peek_kind
501
- if k.is_a?(TOrdinal)
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 parse_on
513
- date = parse_date_target
514
- consume_keyword("'at'", TokenKind::AT)
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 parse_date_target
520
- k = peek_kind
521
- if k.is_a?(TIsoDate)
522
- 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
523
435
  advance
524
- return IsoDate.new(k.date)
525
- end
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
- day = parse_day_number("expected day number after month name")
530
- validate_named_date(month, day)
531
- return NamedDate.new(month, day)
439
+ YearDateTarget.new(kind.name, parse_day_of(kind.name))
440
+ else
441
+ raise expected(Expected::YEAR_TARGET)
532
442
  end
533
- 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)
534
446
  end
535
447
 
536
- def parse_day_target
537
- k = peek_kind
538
- case k
539
- when TokenKind::DAY
540
- advance
541
- DayFilterEvery.new
542
- when TokenKind::WEEKDAY_KW
543
- advance
544
- DayFilterWeekday.new
545
- when TokenKind::WEEKEND_KW
546
- advance
547
- DayFilterWeekend.new
548
- when TDayName
549
- days = parse_day_list
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 error("expected 'day', 'weekday', 'weekend', or day name", current_span)
463
+ raise expected(Expected::YEAR_THE)
553
464
  end
554
465
  end
555
466
 
556
- def parse_day_list
557
- k = peek_kind
558
- raise error("expected day name", current_span) unless k.is_a?(TDayName)
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
- nk = peek_kind
566
- raise error("expected day name after ','", current_span) unless nk.is_a?(TDayName)
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 parse_ordinal_day_list
575
- specs = [parse_ordinal_day_spec]
576
- while peek_kind == TokenKind::COMMA
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 parse_ordinal_day_spec
584
- k = peek_kind
585
- 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)
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
- if peek_kind == TokenKind::TO
593
- advance
594
- nk = peek_kind
595
- raise error("expected ordinal day number after 'to'", current_span) unless nk.is_a?(TOrdinalNumber)
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 parse_nearest_weekday_target
609
- 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
610
504
 
611
- # Optional direction: "next" or "previous"
612
- direction = nil
613
- if k == TokenKind::NEXT
505
+ def parse_day_target
506
+ case peek_kind
507
+ when TokenKind::DAY
614
508
  advance
615
- direction = NearestDirection::NEXT
616
- elsif k == TokenKind::PREVIOUS
509
+ DayFilterEvery.new
510
+ when TokenKind::WEEKDAY_KW
617
511
  advance
618
- direction = NearestDirection::PREVIOUS
619
- end
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 error("expected ordinal day number after 'to'", current_span)
519
+ raise expected(Expected::DAY_TARGET)
635
520
  end
521
+ end
636
522
 
637
- 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
638
529
  end
639
530
 
640
- def parse_month_list
641
- months = [parse_month_name_token]
642
- while peek_kind == TokenKind::COMMA
643
- advance
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
- while peek_kind == TokenKind::COMMA
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
- span = current_span
660
- k = peek_kind
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
- schedule
547
+ advance
548
+ TimeOfDay.new(kind.hour, kind.minute)
549
+ end
680
550
  end
681
551
  end