hron 1.0.0 → 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/lexer.rb CHANGED
@@ -4,7 +4,6 @@ require_relative "ast"
4
4
  require_relative "error"
5
5
 
6
6
  module Hron
7
- # Token kinds (using symbols and Data classes)
8
7
  module TokenKind
9
8
  EVERY = :every
10
9
  ON = :on
@@ -31,7 +30,6 @@ module Hron
31
30
  COMMA = :comma
32
31
  end
33
32
 
34
- # Token types with values
35
33
  TDayName = Data.define(:name)
36
34
  TMonthName = Data.define(:name)
37
35
  TOrdinal = Data.define(:position)
@@ -42,10 +40,8 @@ module Hron
42
40
  TIsoDate = Data.define(:date)
43
41
  TTimezone = Data.define(:tz)
44
42
 
45
- # Token with kind and span
46
43
  Token = Data.define(:kind, :span)
47
44
 
48
- # Keyword mapping
49
45
  KEYWORD_MAP = {
50
46
  "every" => TokenKind::EVERY,
51
47
  "on" => TokenKind::ON,
@@ -75,7 +71,6 @@ module Hron
75
71
  "week" => TokenKind::WEEKS,
76
72
  "month" => TokenKind::MONTH,
77
73
  "months" => TokenKind::MONTH,
78
- # Day names
79
74
  "monday" => TDayName.new(Weekday::MONDAY),
80
75
  "mon" => TDayName.new(Weekday::MONDAY),
81
76
  "tuesday" => TDayName.new(Weekday::TUESDAY),
@@ -90,7 +85,6 @@ module Hron
90
85
  "sat" => TDayName.new(Weekday::SATURDAY),
91
86
  "sunday" => TDayName.new(Weekday::SUNDAY),
92
87
  "sun" => TDayName.new(Weekday::SUNDAY),
93
- # Month names
94
88
  "january" => TMonthName.new(MonthName::JAN),
95
89
  "jan" => TMonthName.new(MonthName::JAN),
96
90
  "february" => TMonthName.new(MonthName::FEB),
@@ -114,13 +108,11 @@ module Hron
114
108
  "nov" => TMonthName.new(MonthName::NOV),
115
109
  "december" => TMonthName.new(MonthName::DEC),
116
110
  "dec" => TMonthName.new(MonthName::DEC),
117
- # Ordinals
118
111
  "first" => TOrdinal.new(OrdinalPosition::FIRST),
119
112
  "second" => TOrdinal.new(OrdinalPosition::SECOND),
120
113
  "third" => TOrdinal.new(OrdinalPosition::THIRD),
121
114
  "fourth" => TOrdinal.new(OrdinalPosition::FOURTH),
122
115
  "fifth" => TOrdinal.new(OrdinalPosition::FIFTH),
123
- # Interval units
124
116
  "min" => TIntervalUnit.new(IntervalUnit::MIN),
125
117
  "mins" => TIntervalUnit.new(IntervalUnit::MIN),
126
118
  "minute" => TIntervalUnit.new(IntervalUnit::MIN),
@@ -130,130 +122,128 @@ module Hron
130
122
  "hr" => TIntervalUnit.new(IntervalUnit::HOURS),
131
123
  "hrs" => TIntervalUnit.new(IntervalUnit::HOURS)
132
124
  }.freeze
125
+ private_constant :TokenKind, :TDayName, :TMonthName, :TOrdinal, :TIntervalUnit, :TNumber, :TOrdinalNumber,
126
+ :TTime, :TIsoDate, :TTimezone, :Token, :KEYWORD_MAP
133
127
 
134
- # Lexer class
135
128
  class Lexer
129
+ # Every number must fit a 32-bit signed integer, which is also the largest interval.
130
+ MAX_NUMBER = 2_147_483_647
131
+ ORDINAL_SUFFIXES = %w[st nd rd th].freeze
132
+ ISO_DATE_TAIL_LENGTH = 6
133
+ private_constant :ORDINAL_SUFFIXES, :ISO_DATE_TAIL_LENGTH
134
+
136
135
  def initialize(input)
137
136
  @input = input
137
+ @chars = Utf8.convert(input).chars
138
138
  @pos = 0
139
- @after_in = false
140
139
  end
141
140
 
142
141
  def tokenize
143
142
  tokens = []
144
143
  loop do
145
- skip_whitespace
146
- break if @pos >= @input.length
147
-
148
- if @after_in
149
- @after_in = false
150
- tokens << lex_timezone
151
- next
152
- end
144
+ advance_while { |c| separator?(c) }
145
+ break if @pos >= @chars.length
153
146
 
154
147
  start = @pos
155
- ch = @input[@pos]
156
-
157
- if ch == ","
148
+ c = @chars[@pos]
149
+ kind = if tokens.last&.kind == TokenKind::IN
150
+ advance_while { |ch| !separator?(ch) }
151
+ TTimezone.new(text(start))
152
+ elsif c == ","
158
153
  @pos += 1
159
- tokens << Token.new(TokenKind::COMMA, Span.new(start, @pos))
160
- next
161
- end
162
-
163
- if ch.match?(/\d/)
164
- tokens << lex_number_or_time_or_date
165
- next
154
+ TokenKind::COMMA
155
+ elsif c.match?(/[A-Za-z]/)
156
+ word(start)
157
+ elsif digit?(c)
158
+ digits(start)
159
+ else
160
+ raise unexpected_character(c, start)
166
161
  end
167
-
168
- if ch.match?(/[a-zA-Z]/)
169
- tokens << lex_word
170
- next
171
- end
172
-
173
- raise HronError.lex("unexpected character '#{ch}'", Span.new(start, start + 1), @input)
162
+ tokens << Token.new(kind, Span.new(start, @pos))
174
163
  end
175
164
  tokens
176
165
  end
177
166
 
178
167
  private
179
168
 
180
- def skip_whitespace
181
- @pos += 1 while @pos < @input.length && @input[@pos].match?(/\s/)
169
+ # Only these four separate tokens; `\s` and `strip` would also take \v, \f or NUL.
170
+ def separator?(c)
171
+ c == " " || c == "\t" || c == "\r" || c == "\n"
182
172
  end
183
173
 
184
- def lex_timezone
185
- skip_whitespace
186
- start = @pos
187
- @pos += 1 while @pos < @input.length && !@input[@pos].match?(/\s/)
188
- tz = @input[start...@pos]
189
- raise HronError.lex("expected timezone after 'in'", Span.new(start, start + 1), @input) if tz.empty?
174
+ def digit?(c)
175
+ c&.match?(/[0-9]/)
176
+ end
190
177
 
191
- Token.new(TTimezone.new(tz), Span.new(start, @pos))
178
+ def advance_while
179
+ @pos += 1 while @pos < @chars.length && yield(@chars[@pos])
192
180
  end
193
181
 
194
- def lex_number_or_time_or_date
195
- start = @pos
196
- @pos += 1 while @pos < @input.length && @input[@pos].match?(/\d/)
197
- digits = @input[start...@pos]
182
+ def text(start, stop = @pos)
183
+ @chars[start...stop].join
184
+ end
198
185
 
199
- # Check for ISO date: YYYY-MM-DD
200
- if digits.length == 4 && @pos < @input.length && @input[@pos] == "-"
201
- remaining = @input[start..]
202
- if remaining.length >= 10 &&
203
- remaining[4] == "-" &&
204
- remaining[5..6].match?(/\d{2}/) &&
205
- remaining[7] == "-" &&
206
- remaining[8..9].match?(/\d{2}/)
207
- @pos = start + 10
208
- return Token.new(TIsoDate.new(@input[start...@pos]), Span.new(start, @pos))
209
- end
210
- end
186
+ def error(message, start)
187
+ HronError.lex(message, Span.new(start, @pos), @input)
188
+ end
211
189
 
212
- # Check for time: HH:MM
213
- if digits.length.between?(1, 2) && @pos < @input.length && @input[@pos] == ":"
214
- @pos += 1 # skip ':'
215
- min_start = @pos
216
- @pos += 1 while @pos < @input.length && @input[@pos].match?(/\d/)
217
- min_digits = @input[min_start...@pos]
218
- if min_digits.length == 2
219
- hour = digits.to_i
220
- minute = min_digits.to_i
221
- raise HronError.lex("invalid time", Span.new(start, @pos), @input) if hour > 23 || minute > 59
190
+ def word(start)
191
+ advance_while { |c| c.match?(/[A-Za-z0-9_]/) }
192
+ written = text(start)
193
+ KEYWORD_MAP[written.downcase(:ascii)] or raise error("unknown keyword '#{written}'", start)
194
+ end
222
195
 
223
- return Token.new(TTime.new(hour, minute), Span.new(start, @pos))
224
- end
196
+ def digits(start)
197
+ advance_while { |c| digit?(c) }
198
+ if @pos - start == 4 && iso_date_tail?
199
+ @pos += ISO_DATE_TAIL_LENGTH
200
+ return TIsoDate.new(text(start))
225
201
  end
202
+ return time(start) if @chars[@pos] == ":"
226
203
 
227
- num = digits.to_i
228
-
229
- # Check for ordinal suffix: st, nd, rd, th
230
- if @pos + 1 < @input.length
231
- suffix = @input[@pos, 2].downcase
232
- if %w[st nd rd th].include?(suffix)
233
- @pos += 2
234
- return Token.new(TOrdinalNumber.new(num), Span.new(start, @pos))
235
- end
204
+ value = number_value(text(start)) or raise error("number must be at most #{MAX_NUMBER}", start)
205
+ if ORDINAL_SUFFIXES.include?(@chars[@pos, 2].join.downcase(:ascii))
206
+ @pos += 2
207
+ return TOrdinalNumber.new(value)
236
208
  end
209
+ TNumber.new(value)
210
+ end
237
211
 
238
- Token.new(TNumber.new(num), Span.new(start, @pos))
212
+ def iso_date_tail?
213
+ tail = @chars[@pos, ISO_DATE_TAIL_LENGTH]
214
+ tail.length == ISO_DATE_TAIL_LENGTH && tail[0] == "-" && tail[3] == "-" && [1, 2, 4, 5].all? { |i| digit?(tail[i]) }
239
215
  end
240
216
 
241
- def lex_word
242
- start = @pos
243
- @pos += 1 while @pos < @input.length && @input[@pos].match?(/\w/)
244
- word = @input[start...@pos].downcase
245
- span = Span.new(start, @pos)
217
+ # Integer() would read a leading zero as octal and accept "_" or a sign, and stopping at the
218
+ # limit keeps a run of thousands of digits from becoming a huge Integer.
219
+ def number_value(digits)
220
+ digits.each_char.reduce(0) do |n, d|
221
+ n = (n * 10) + d.ord - "0".ord
222
+ return nil if n > MAX_NUMBER
246
223
 
247
- kind = KEYWORD_MAP[word]
248
- raise HronError.lex("unknown keyword '#{word}'", span, @input) if kind.nil?
224
+ n
225
+ end
226
+ end
249
227
 
250
- @after_in = true if kind == TokenKind::IN
228
+ def time(start)
229
+ colon = @pos
230
+ @pos += 1
231
+ advance_while { |c| digit?(c) }
232
+ hour = text(start, colon)
233
+ minute = text(colon + 1)
234
+ written = text(start)
235
+ unless hour.length.between?(1, 2) && minute.length == 2
236
+ raise error("time must be H:MM or HH:MM, got #{written}", start)
237
+ end
238
+ raise error("time must be 00:00-23:59, got #{written}", start) if hour.to_i > 23 || minute.to_i > 59
251
239
 
252
- Token.new(kind, span)
240
+ TTime.new(hour.to_i, minute.to_i)
253
241
  end
254
- end
255
242
 
256
- def self.tokenize(input)
257
- Lexer.new(input).tokenize
243
+ def unexpected_character(c, start)
244
+ # `'` is excluded because `'''` would not read as a quoted character.
245
+ shown = (c.ord.between?(0x21, 0x7E) && c != "'") ? "'#{c}'" : format("U+%04X", c.ord)
246
+ HronError.lex("unexpected character #{shown}", Span.new(start, start + 1), @input)
247
+ end
258
248
  end
259
249
  end