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/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.expr)
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.anchor}" if schedule.anchor
35
+ out += " starting #{schedule.starting}" if schedule.starting
37
36
 
38
37
  out += " during #{schedule.during.join(", ")}" unless schedule.during.empty?
39
38
 
@@ -59,7 +58,11 @@ module Hron
59
58
 
60
59
  when WeekRepeat
61
60
  day_str = expr.days.join(", ")
62
- "every #{expr.interval} weeks on #{day_str} at #{format_time_list(expr.times)}"
61
+ if expr.interval > 1
62
+ "every #{expr.interval} weeks on #{day_str} at #{format_time_list(expr.times)}"
63
+ else
64
+ "every week on #{day_str} at #{format_time_list(expr.times)}"
65
+ end
63
66
 
64
67
  when MonthRepeat
65
68
  target_str = case expr.target
@@ -142,7 +145,7 @@ module Hron
142
145
  end
143
146
 
144
147
  def self.format_time_list(times)
145
- times.map(&:to_s).join(", ")
148
+ times.join(", ")
146
149
  end
147
150
 
148
151
  def self.format_ordinal_day_specs(specs)
@@ -157,7 +160,10 @@ module Hron
157
160
  parts.join(", ")
158
161
  end
159
162
 
163
+ # Ruby's % floors, so -9 % 10 is 1; spec/README.md writes a day below 1 with th.
160
164
  def self.ordinal_suffix(n)
165
+ return "th" if n < 1
166
+
161
167
  mod100 = n % 100
162
168
  return "th" if mod100.between?(11, 13)
163
169
 
data/lib/hron/error.rb CHANGED
@@ -1,14 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hron
4
- # Span in source input for error reporting
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
- # Main error class for hron
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 display_rich
48
- if [ErrorKind::LEX, ErrorKind::PARSE].include?(kind) && span && input
49
- buf = []
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