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.
- 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/display.rb
CHANGED
|
@@ -3,10 +3,9 @@
|
|
|
3
3
|
require_relative "ast"
|
|
4
4
|
|
|
5
5
|
module Hron
|
|
6
|
-
# Display module for converting schedules to canonical string representation
|
|
7
6
|
module Display
|
|
8
7
|
def self.display(schedule)
|
|
9
|
-
out = display_expr(schedule.
|
|
8
|
+
out = display_expr(schedule.expression)
|
|
10
9
|
|
|
11
10
|
unless schedule.except.empty?
|
|
12
11
|
parts = schedule.except.map do |exc|
|
|
@@ -33,7 +32,7 @@ module Hron
|
|
|
33
32
|
end
|
|
34
33
|
end
|
|
35
34
|
|
|
36
|
-
out += " starting #{schedule.
|
|
35
|
+
out += " starting #{schedule.starting}" if schedule.starting
|
|
37
36
|
|
|
38
37
|
out += " during #{schedule.during.join(", ")}" unless schedule.during.empty?
|
|
39
38
|
|
|
@@ -146,7 +145,7 @@ module Hron
|
|
|
146
145
|
end
|
|
147
146
|
|
|
148
147
|
def self.format_time_list(times)
|
|
149
|
-
times.
|
|
148
|
+
times.join(", ")
|
|
150
149
|
end
|
|
151
150
|
|
|
152
151
|
def self.format_ordinal_day_specs(specs)
|
|
@@ -161,7 +160,10 @@ module Hron
|
|
|
161
160
|
parts.join(", ")
|
|
162
161
|
end
|
|
163
162
|
|
|
163
|
+
# Ruby's % floors, so -9 % 10 is 1; spec/README.md writes a day below 1 with th.
|
|
164
164
|
def self.ordinal_suffix(n)
|
|
165
|
+
return "th" if n < 1
|
|
166
|
+
|
|
165
167
|
mod100 = n % 100
|
|
166
168
|
return "th" if mod100.between?(11, 13)
|
|
167
169
|
|
data/lib/hron/error.rb
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Hron
|
|
4
|
-
#
|
|
4
|
+
# The part of the input an error points at: [start, end_pos) counted in code points, which are
|
|
5
|
+
# the indices of a valid UTF-8 String. Each byte of invalid UTF-8 counts as one code point.
|
|
5
6
|
Span = Data.define(:start, :end_pos) do # end_pos to avoid Ruby keyword
|
|
6
7
|
def length
|
|
7
8
|
end_pos - start
|
|
8
9
|
end
|
|
9
10
|
end
|
|
10
11
|
|
|
11
|
-
# Error kinds
|
|
12
12
|
module ErrorKind
|
|
13
13
|
LEX = :lex
|
|
14
14
|
PARSE = :parse
|
|
@@ -16,7 +16,25 @@ module Hron
|
|
|
16
16
|
CRON = :cron
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
module Utf8
|
|
20
|
+
REPLACEMENT = "�"
|
|
21
|
+
|
|
22
|
+
# The spec counts each byte of invalid UTF-8 as one U+FFFD; `scrub` alone would replace a
|
|
23
|
+
# truncated multi-byte sequence with a single one. A binary String is read as UTF-8 bytes.
|
|
24
|
+
def self.convert(input)
|
|
25
|
+
return input if input.encoding == Encoding::UTF_8 && input.valid_encoding?
|
|
26
|
+
|
|
27
|
+
if input.encoding == Encoding::UTF_8 || input.encoding == Encoding::BINARY
|
|
28
|
+
return input.b.force_encoding(Encoding::UTF_8).scrub { |bytes| REPLACEMENT * bytes.bytesize }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
input.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: REPLACEMENT)
|
|
32
|
+
rescue EncodingError
|
|
33
|
+
input.b.force_encoding(Encoding::UTF_8).scrub { |bytes| REPLACEMENT * bytes.bytesize }
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
private_constant :Utf8
|
|
37
|
+
|
|
20
38
|
class HronError < StandardError
|
|
21
39
|
attr_reader :kind, :span, :input, :suggestion
|
|
22
40
|
|
|
@@ -28,37 +46,56 @@ module Hron
|
|
|
28
46
|
@suggestion = suggestion
|
|
29
47
|
end
|
|
30
48
|
|
|
49
|
+
# Each factory raises TypeError for a message or input that is not a String, a span that is
|
|
50
|
+
# not a Hron::Span, and a suggestion that is neither a String nor nil.
|
|
31
51
|
def self.lex(message, span, input)
|
|
52
|
+
require_strings(message: message, input: input)
|
|
53
|
+
require_span(span)
|
|
32
54
|
new(ErrorKind::LEX, message, span: span, input: input)
|
|
33
55
|
end
|
|
34
56
|
|
|
35
57
|
def self.parse(message, span, input, suggestion: nil)
|
|
58
|
+
require_strings(message: message, input: input)
|
|
59
|
+
require_span(span)
|
|
60
|
+
unless suggestion.nil? || suggestion.is_a?(String)
|
|
61
|
+
raise TypeError, "suggestion must be a String or nil, not #{suggestion.class}"
|
|
62
|
+
end
|
|
63
|
+
|
|
36
64
|
new(ErrorKind::PARSE, message, span: span, input: input, suggestion: suggestion)
|
|
37
65
|
end
|
|
38
66
|
|
|
39
67
|
def self.eval(message)
|
|
68
|
+
require_strings(message: message)
|
|
40
69
|
new(ErrorKind::EVAL, message)
|
|
41
70
|
end
|
|
42
71
|
|
|
43
72
|
def self.cron(message)
|
|
73
|
+
require_strings(message: message)
|
|
44
74
|
new(ErrorKind::CRON, message)
|
|
45
75
|
end
|
|
46
76
|
|
|
47
|
-
def
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
buf << "error: #{message}"
|
|
51
|
-
buf << " #{input}"
|
|
52
|
-
padding = " " * (span.start + 2)
|
|
53
|
-
len = [span.length, 1].max
|
|
54
|
-
underline = "^" * len
|
|
55
|
-
line = "#{padding}#{underline}"
|
|
56
|
-
line += " try: \"#{suggestion}\"" if suggestion
|
|
57
|
-
buf << line
|
|
58
|
-
buf.join("\n")
|
|
59
|
-
else
|
|
60
|
-
"error: #{message}"
|
|
77
|
+
def self.require_strings(**values)
|
|
78
|
+
values.each do |name, value|
|
|
79
|
+
raise TypeError, "#{name} must be a String, not #{value.class}" unless value.is_a?(String)
|
|
61
80
|
end
|
|
62
81
|
end
|
|
82
|
+
private_class_method :require_strings
|
|
83
|
+
|
|
84
|
+
def self.require_span(span)
|
|
85
|
+
raise TypeError, "span must be a Hron::Span, not #{span.class}" unless span.is_a?(Span)
|
|
86
|
+
end
|
|
87
|
+
private_class_method :require_span
|
|
88
|
+
|
|
89
|
+
# The message, then for lex and parse errors the input and a line of carets under the span,
|
|
90
|
+
# and any suggestion as ` try: "..."`. Lines are joined by "\n", with no trailing newline.
|
|
91
|
+
def display_rich
|
|
92
|
+
return "error: #{message}" unless [ErrorKind::LEX, ErrorKind::PARSE].include?(kind) && span && input
|
|
93
|
+
|
|
94
|
+
# A tab, CR or LF would move the input off the line the carets are aligned to.
|
|
95
|
+
shown = Utf8.convert(input).tr("\t\r\n", " ")
|
|
96
|
+
carets = " #{" " * span.start}#{"^" * [span.length, 1].max}"
|
|
97
|
+
carets += " try: \"#{suggestion}\"" if suggestion
|
|
98
|
+
["error: #{message}", " #{shown}", carets].join("\n")
|
|
99
|
+
end
|
|
63
100
|
end
|
|
64
101
|
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require_relative "../ast"
|
|
5
|
+
|
|
6
|
+
module Hron
|
|
7
|
+
class Evaluator
|
|
8
|
+
# Proleptic Gregorian throughout: Ruby's Date otherwise switches to the Julian calendar
|
|
9
|
+
# before 1582, and a search can reach back that far.
|
|
10
|
+
module Calendar
|
|
11
|
+
# The calendar a search walks: no date outside it holds an occurrence in the
|
|
12
|
+
# supported range (spec/README.md, "Supported range").
|
|
13
|
+
FIRST_DATE = Date.new(1, 1, 1, Date::GREGORIAN)
|
|
14
|
+
LAST_DATE = Date.new(9999, 12, 31, Date::GREGORIAN)
|
|
15
|
+
|
|
16
|
+
FRIDAY = 5
|
|
17
|
+
SATURDAY = 6
|
|
18
|
+
SUNDAY = 7
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
def date(year, month, day)
|
|
23
|
+
Date.new(year, month, day, Date::GREGORIAN) if Date.valid_date?(year, month, day, Date::GREGORIAN)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def parse_date(iso)
|
|
27
|
+
Date.iso8601(iso, Date::GREGORIAN)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def days_between(from, to)
|
|
31
|
+
to.jd - from.jd
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def month_index(date)
|
|
35
|
+
(date.year * 12) + date.month - 1
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def first_of_month(index)
|
|
39
|
+
year, month = index.divmod(12)
|
|
40
|
+
Date.new(year, month + 1, 1, Date::GREGORIAN)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def first_of_year(year)
|
|
44
|
+
Date.new(year, 1, 1, Date::GREGORIAN)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def monday_of_week(date)
|
|
48
|
+
date - (date.cwday - 1)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def matches_day_filter?(date, filter)
|
|
52
|
+
case filter
|
|
53
|
+
when nil, DayFilterEvery then true
|
|
54
|
+
when DayFilterWeekday then date.cwday <= FRIDAY
|
|
55
|
+
when DayFilterWeekend then date.cwday > FRIDAY
|
|
56
|
+
when DayFilterDays then filter.days.any? { |day| Weekday.number(day) == date.cwday }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def month_target_dates(year, month, target)
|
|
61
|
+
case target
|
|
62
|
+
when DaysTarget
|
|
63
|
+
Evaluator.days_of(target).uniq.sort.filter_map { |day| date(year, month, day) }
|
|
64
|
+
when LastDayTarget then [last_day_of_month(year, month)]
|
|
65
|
+
when LastWeekdayTarget then [last_weekday_of_month(year, month)]
|
|
66
|
+
when NearestWeekdayTarget then [nearest_weekday(year, month, target.day, target.direction)].compact
|
|
67
|
+
when OrdinalWeekdayTarget then [ordinal_weekday(year, month, target.ordinal, target.weekday)].compact
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def year_target_date(year, target)
|
|
72
|
+
month = MonthName.number(target.month)
|
|
73
|
+
case target
|
|
74
|
+
when YearDateTarget, YearDayOfMonthTarget then date(year, month, target.day)
|
|
75
|
+
when YearOrdinalWeekdayTarget then ordinal_weekday(year, month, target.ordinal, target.weekday)
|
|
76
|
+
when YearLastWeekdayTarget then last_weekday_of_month(year, month)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def last_day_of_month(year, month)
|
|
81
|
+
Date.new(year, month, -1, Date::GREGORIAN)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def last_weekday_of_month(year, month)
|
|
85
|
+
last = last_day_of_month(year, month)
|
|
86
|
+
last - [last.cwday - FRIDAY, 0].max
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def ordinal_weekday(year, month, ordinal, weekday)
|
|
90
|
+
target = Weekday.number(weekday)
|
|
91
|
+
if ordinal == OrdinalPosition::LAST
|
|
92
|
+
last = last_day_of_month(year, month)
|
|
93
|
+
return last - ((last.cwday - target) % 7)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
first = Date.new(year, month, 1, Date::GREGORIAN)
|
|
97
|
+
nth = first + (((target - first.cwday) % 7) + (7 * (OrdinalPosition.to_n(ordinal) - 1)))
|
|
98
|
+
nth if nth.month == month
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Without a direction it stays in the month, as cron's W does; with one it can cross into
|
|
102
|
+
# the adjacent month (spec/README.md, "Nearest weekday and `during`").
|
|
103
|
+
def nearest_weekday(year, month, day, toward)
|
|
104
|
+
target = date(year, month, day)
|
|
105
|
+
return target if target.nil? || target.cwday <= FRIDAY
|
|
106
|
+
|
|
107
|
+
target + case [target.cwday, toward]
|
|
108
|
+
in [SATURDAY, NearestDirection::NEXT] then 2
|
|
109
|
+
in [SATURDAY, NearestDirection::PREVIOUS] then -1
|
|
110
|
+
in [SATURDAY, nil] if day == 1 then 2
|
|
111
|
+
in [SATURDAY, nil] then -1
|
|
112
|
+
in [SUNDAY, NearestDirection::NEXT] then 1
|
|
113
|
+
in [SUNDAY, NearestDirection::PREVIOUS] then -2
|
|
114
|
+
in [SUNDAY, nil] if target == last_day_of_month(year, month) then -2
|
|
115
|
+
in [SUNDAY, nil] then 1
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Hron
|
|
7
|
+
class Evaluator
|
|
8
|
+
# A wall time a fall-back repeats takes its first pass
|
|
9
|
+
# (spec/README.md, "DST fall-back (ambiguous times)").
|
|
10
|
+
module WallClock
|
|
11
|
+
DAY_SECONDS = 86_400
|
|
12
|
+
MINUTES_PER_HOUR = 60
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# A wall time in a spring-forward gap shifts forward by the gap's length (spec/README.md,
|
|
17
|
+
# "DST spring-forward (gaps)"). A Time carries no zone, so this also returns the date it
|
|
18
|
+
# lands on.
|
|
19
|
+
def fixed_time_on(date, time, zone)
|
|
20
|
+
wall = wall_time(date, time.hour, time.minute)
|
|
21
|
+
instant = first_pass(wall, zone) { nil }
|
|
22
|
+
return instant, date if instant
|
|
23
|
+
|
|
24
|
+
# Read with the offset in force before the gap, a wall time in it lands past it, where
|
|
25
|
+
# the offset after the gap reads it.
|
|
26
|
+
transition = gap_transition(wall, zone)
|
|
27
|
+
instant = wall - transition.previous_offset.utc_total_offset
|
|
28
|
+
local = instant + transition.offset.utc_total_offset
|
|
29
|
+
[instant, Date.new(local.year, local.month, local.day, Date::GREGORIAN)]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# A slot in a spring-forward gap has no instant (spec/README.md, "Interval slots in a
|
|
33
|
+
# spring-forward gap"); its key is the instant the gap ends, so keys never decrease in
|
|
34
|
+
# wall-clock order and one binary search finds the slots on either side of an instant.
|
|
35
|
+
# A Struct, as a binary search makes several for every date.
|
|
36
|
+
Slot = Struct.new(:key, :instant)
|
|
37
|
+
|
|
38
|
+
def slot_on(date, minute, zone)
|
|
39
|
+
wall = wall_time(date, *minute.divmod(MINUTES_PER_HOUR))
|
|
40
|
+
instant = first_pass(wall, zone) { nil }
|
|
41
|
+
Slot.new(instant || gap_transition(wall, zone).at.to_time, instant)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def minute_of_day(time)
|
|
45
|
+
(time.hour * MINUTES_PER_HOUR) + time.minute
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def first_pass(wall, zone)
|
|
49
|
+
offset = zone.periods_for_local(wall).first&.offset&.utc_total_offset
|
|
50
|
+
offset ? wall - offset : yield(wall)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The wall time as a UTC Time, so the system time zone never interferes.
|
|
54
|
+
def wall_time(date, hour, minute)
|
|
55
|
+
Time.utc(date.year, date.month, date.day, hour, minute)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def gap_transition(wall, zone)
|
|
59
|
+
zone.transitions_up_to(wall + DAY_SECONDS, wall - DAY_SECONDS).rfind do |transition|
|
|
60
|
+
transition.at.to_time + transition.previous_offset.utc_total_offset <= wall
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|