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.
- checksums.yaml +4 -4
- data/README.md +126 -21
- data/hron.gemspec +7 -11
- data/lib/hron/ast.rb +28 -54
- data/lib/hron/cron.rb +369 -652
- data/lib/hron/display.rb +6 -4
- data/lib/hron/error.rb +54 -17
- data/lib/hron/eval/calendar.rb +120 -0
- data/lib/hron/eval/wall_clock.rb +65 -0
- data/lib/hron/evaluator.rb +339 -1010
- data/lib/hron/lexer.rb +84 -94
- data/lib/hron/parser.rb +369 -502
- data/lib/hron/parts.rb +257 -0
- data/lib/hron/schedule.rb +81 -19
- data/lib/hron/version.rb +1 -1
- data/lib/hron.rb +13 -3
- metadata +14 -53
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
|
-
|
|
146
|
-
break if @pos >= @
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
181
|
-
|
|
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
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
178
|
+
def advance_while
|
|
179
|
+
@pos += 1 while @pos < @chars.length && yield(@chars[@pos])
|
|
192
180
|
end
|
|
193
181
|
|
|
194
|
-
def
|
|
195
|
-
start
|
|
196
|
-
|
|
197
|
-
digits = @input[start...@pos]
|
|
182
|
+
def text(start, stop = @pos)
|
|
183
|
+
@chars[start...stop].join
|
|
184
|
+
end
|
|
198
185
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
224
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
248
|
-
|
|
224
|
+
n
|
|
225
|
+
end
|
|
226
|
+
end
|
|
249
227
|
|
|
250
|
-
|
|
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
|
-
|
|
240
|
+
TTime.new(hour.to_i, minute.to_i)
|
|
253
241
|
end
|
|
254
|
-
end
|
|
255
242
|
|
|
256
|
-
|
|
257
|
-
|
|
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
|