aws-cron-parser 0.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.
Files changed (38) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +32 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +117 -0
  5. data/lib/aws_cron_parser/describer/cron_composer.rb +198 -0
  6. data/lib/aws_cron_parser/describer/day_composer.rb +74 -0
  7. data/lib/aws_cron_parser/describer/field_describers/base.rb +108 -0
  8. data/lib/aws_cron_parser/describer/field_describers/day_of_month.rb +88 -0
  9. data/lib/aws_cron_parser/describer/field_describers/day_of_week.rb +143 -0
  10. data/lib/aws_cron_parser/describer/field_describers/month.rb +92 -0
  11. data/lib/aws_cron_parser/describer/field_describers/time_field.rb +101 -0
  12. data/lib/aws_cron_parser/describer/month_composer.rb +29 -0
  13. data/lib/aws_cron_parser/describer/template.en.yml +183 -0
  14. data/lib/aws_cron_parser/describer/template.rb +87 -0
  15. data/lib/aws_cron_parser/describer/time_composer.rb +255 -0
  16. data/lib/aws_cron_parser/describer.rb +131 -0
  17. data/lib/aws_cron_parser/fugit_adapter.rb +64 -0
  18. data/lib/aws_cron_parser/helpers/calendar.rb +115 -0
  19. data/lib/aws_cron_parser/helpers/clock.rb +50 -0
  20. data/lib/aws_cron_parser/helpers/cron_expression.rb +277 -0
  21. data/lib/aws_cron_parser/helpers/numbers.rb +32 -0
  22. data/lib/aws_cron_parser/helpers.rb +17 -0
  23. data/lib/aws_cron_parser/parser.rb +152 -0
  24. data/lib/aws_cron_parser/parsers/aws_at.rb +32 -0
  25. data/lib/aws_cron_parser/parsers/aws_cron.rb +413 -0
  26. data/lib/aws_cron_parser/parsers/aws_rate.rb +33 -0
  27. data/lib/aws_cron_parser/parsers/base.rb +21 -0
  28. data/lib/aws_cron_parser/parsers/quartz_cron.rb +21 -0
  29. data/lib/aws_cron_parser/validator.rb +31 -0
  30. data/lib/aws_cron_parser/validators/aws_at_validator.rb +28 -0
  31. data/lib/aws_cron_parser/validators/aws_cron_validator.rb +212 -0
  32. data/lib/aws_cron_parser/validators/aws_macro_validator.rb +13 -0
  33. data/lib/aws_cron_parser/validators/aws_rate_validator.rb +17 -0
  34. data/lib/aws_cron_parser/validators/base.rb +23 -0
  35. data/lib/aws_cron_parser/validators/quartz_cron_validator.rb +132 -0
  36. data/lib/aws_cron_parser/version.rb +5 -0
  37. data/lib/aws_cron_parser.rb +48 -0
  38. metadata +127 -0
@@ -0,0 +1,277 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AWSCronParser
4
+ module Helpers
5
+ module CronExpression
6
+ include AWSCronParser::Helpers::Calendar
7
+
8
+ # Expression type patterns
9
+ AWS_CRON_REGEX = /^cron\((?<expression>.+)\)$/i
10
+ RATE_REGEX = /^rate\((?<value>\d+)\s+(?<unit>minute|minutes|hour|hours|day|days)\)$/i
11
+ MACRO_REGEX = /^@(?<macro>yearly|monthly|weekly|daily|hourly)$/i
12
+ AT_REGEX = /^at\((?<datetime>.+)\)$/i
13
+
14
+ # Consolidated CRON field patterns
15
+ CRON_PATTERNS = {
16
+ digit_only: /^\d+$/,
17
+ step: %r{^\*/(\d+)$|^0/(\d+)$},
18
+ range: /^(\d+)-(\d+)$/,
19
+ step_range: %r{(\d+)/(\d+)},
20
+ nth_weekday_numeric: /^\d+#\d+$/,
21
+ last_weekday_numeric: /^\d+L$/,
22
+ nth_weekday: /^(?<weekday>\d)#(?<occurrence>\d)$/,
23
+ nth_weekday_name: /^(?<name>[A-Z]{3}|[A-Z]+)#(?<occurrence>\d+)$/i,
24
+ last_weekday: /^(?<weekday>\d)L$/,
25
+ last_weekday_name: /^(?<name>[A-Z]{3}|[A-Z]+)L$/i
26
+ }.freeze
27
+
28
+ # Backward compatibility - individual constants for existing code
29
+ DIGIT_ONLY_REGEX = CRON_PATTERNS[:digit_only]
30
+ RANGE_REGEX = CRON_PATTERNS[:range]
31
+ STEP_RANGE_REGEX = CRON_PATTERNS[:step_range]
32
+ NTH_WEEKDAY_REGEX = CRON_PATTERNS[:nth_weekday]
33
+ LAST_WEEKDAY_REGEX = CRON_PATTERNS[:last_weekday]
34
+
35
+ # Normalize expression by stripping cron() wrapper if present
36
+ def normalize_source(source)
37
+ return source if source.blank?
38
+
39
+ normalized = source.strip
40
+ if (match = normalized.match(AWS_CRON_REGEX))
41
+ content = match[:expression].strip
42
+ parts = content.split(/\s+/)
43
+ normalized = if parts.length == 7
44
+ parts[0..5].join(' ')
45
+ elsif parts.length == 6
46
+ parts[0..4].join(' ')
47
+ else
48
+ parts.join(' ')
49
+ end
50
+ end
51
+
52
+ normalized
53
+ end
54
+
55
+ # Detect if a specific pattern character exists in the day-of-week field
56
+ def detect_pattern(source, pattern_char)
57
+ tokens = source.split(/\s+/)
58
+ return false if tokens.length < 5
59
+
60
+ dow_field = tokens.length == 5 ? tokens[4] : tokens[5]
61
+ dow_field.include?(pattern_char)
62
+ end
63
+
64
+ # Detect if a specific pattern character exists in the day-of-month field
65
+ def detect_dom_pattern(source, pattern_char)
66
+ tokens = source.split(/\s+/)
67
+ return false if tokens.length < 5
68
+
69
+ dom_field = tokens.length == 5 ? tokens[2] : tokens[3]
70
+ dom_field.include?(pattern_char)
71
+ end
72
+
73
+ # Detect if the CRON expression uses complex step patterns (with ranges or lists)
74
+ def detect_complex_step(source)
75
+ tokens = source.split(/\s+/)
76
+ return false if tokens.length < 5
77
+
78
+ tokens.each do |field|
79
+ return true if field.include?('/') && field.exclude?('#') && field.exclude?('L')
80
+ end
81
+ false
82
+ end
83
+
84
+ # Check if month field matches actual month
85
+ def month_only?(source)
86
+ tokens = source.split(/\s+/)
87
+ return false if tokens.length < 5
88
+
89
+ dom_idx = tokens.length == 5 ? 2 : 3
90
+ dow_idx = tokens.length == 5 ? 4 : 5
91
+
92
+ dom = tokens[dom_idx]
93
+ dow = tokens[dow_idx]
94
+
95
+ ['*', '?'].include?(dom) && dow != '*' && dow != '?' && dow.exclude?('#') && dow.exclude?('L')
96
+ end
97
+
98
+ # Check if day-of-month field is specified and day-of-week is '?'
99
+ def day_only?(source)
100
+ tokens = source.split(/\s+/)
101
+ return false if tokens.length < 5
102
+
103
+ dom_idx = tokens.length == 5 ? 2 : 3
104
+ dow_idx = tokens.length == 5 ? 4 : 5
105
+
106
+ dom = tokens[dom_idx]
107
+ dow = tokens[dow_idx]
108
+
109
+ dom != '?' && dow == '?'
110
+ end
111
+
112
+ # Parse a simple cron field (minute, hour, day, month, or weekday)
113
+ def parse_simple_field(field, range)
114
+ return range.to_a if ['*', '?'].include?(field)
115
+ return [field.to_i] if field.match?(/^\d+$/)
116
+
117
+ if field.include?('-')
118
+ start_num, end_num = field.split('-').map(&:to_i)
119
+ (start_num..end_num).to_a
120
+ elsif field.include?(',')
121
+ field.split(',').map(&:to_i)
122
+ elsif field.include?('/')
123
+ start_str, step_str = field.split('/')
124
+ start = start_str == '*' ? range.first : start_str.to_i
125
+ step = step_str.to_i
126
+ result = []
127
+ current = start
128
+ while current <= range.last
129
+ result << current
130
+ current += step
131
+ end
132
+ result
133
+ else
134
+ [field.to_i]
135
+ end
136
+ end
137
+
138
+ # Parse the day-of-week field (0 = Sunday, 1 = Monday, ..., 6 = Saturday)
139
+ def parse_dow_field(field)
140
+ return (0..6).to_a if ['*', '?'].include?(field)
141
+
142
+ result = []
143
+ field.split(',').each do |part|
144
+ part = part.strip
145
+ if part.include?('/')
146
+ result.concat(expand_dow_step(part))
147
+ elsif part.include?('-')
148
+ start_num, end_num = part.split('-')
149
+ start_num = AWS_DAY_NAMES[start_num.upcase] || start_num.to_i
150
+ end_num = AWS_DAY_NAMES[end_num.upcase] || end_num.to_i
151
+ (start_num..end_num).each { |n| result << n }
152
+ elsif part.match?(/^\d+$/)
153
+ result << part.to_i
154
+ elsif AWS_DAY_NAMES[part.upcase]
155
+ result << AWS_DAY_NAMES[part.upcase]
156
+ end
157
+ end
158
+ result.sort.uniq
159
+ end
160
+
161
+ # Expands */2, MON/2 or 2-6/2 over the AWS weekday range (1=SUN..7=SAT).
162
+ # Without this the field yields no days at all and the caller searches a
163
+ # whole year before giving up.
164
+ def expand_dow_step(part)
165
+ base, step = part.split('/')
166
+ step = step.to_i
167
+ return [] if step <= 0
168
+
169
+ if base.include?('-')
170
+ start_num, end_num = base.split('-').map { |v| AWS_DAY_NAMES[v.upcase] || v.to_i }
171
+ elsif ['*', '?'].include?(base)
172
+ start_num = 1
173
+ end_num = 7
174
+ else
175
+ start_num = AWS_DAY_NAMES[base.upcase] || base.to_i
176
+ end_num = 7
177
+ end
178
+
179
+ (start_num..end_num).step(step).to_a
180
+ end
181
+
182
+ # Normalize weekday in nth spec (e.g., "MON#2" to "1#2")
183
+ def normalize_weekday_in_nth_spec(dow_spec, is_aws: false)
184
+ return dow_spec if dow_spec.match?(CRON_PATTERNS[:nth_weekday_numeric])
185
+
186
+ match = dow_spec.match(CRON_PATTERNS[:nth_weekday_name])
187
+ return dow_spec unless match
188
+
189
+ day_names = is_aws ? AWS_DAY_NAMES : DAY_NAMES
190
+ day_num = day_names[match[:name].upcase] || match[:name]
191
+ "#{day_num}##{match[:occurrence]}"
192
+ end
193
+
194
+ # Normalize weekday field (e.g., "mon", "tue-wed", "fri/2")
195
+ def normalize_weekday_field(weekday, is_aws: false)
196
+ return weekday if ['*', '?'].include?(weekday) || weekday =~ /^\d/
197
+
198
+ # Handle ranges like "mon-wed"
199
+ if weekday.include?('-')
200
+ parts = weekday.split('-')
201
+ start_num = weekday_abbr_to_num(parts[0], is_aws: is_aws)
202
+ end_num = weekday_abbr_to_num(parts[1], is_aws: is_aws)
203
+ return "#{start_num}-#{end_num}"
204
+ end
205
+
206
+ # Handle lists like "mon,wed,fri"
207
+ if weekday.include?(',')
208
+ parts = weekday.split(',').map { |w| weekday_abbr_to_num(w, is_aws: is_aws) }
209
+ return parts.join(',')
210
+ end
211
+
212
+ # Handle steps like "mon/2" and "*/2" - the wildcard base is not a name
213
+ if weekday.include?('/')
214
+ parts = weekday.split('/')
215
+ start = ['*', '?'].include?(parts[0]) ? parts[0] : weekday_abbr_to_num(parts[0], is_aws: is_aws)
216
+ return "#{start}/#{parts[1]}"
217
+ end
218
+
219
+ # Single weekday abbreviation
220
+ weekday_abbr_to_num(weekday, is_aws: is_aws).to_s
221
+ end
222
+
223
+ # Normalize weekday in last spec (e.g., "MONL" to "1L")
224
+ def normalize_weekday_in_last_spec(dow_spec)
225
+ return dow_spec if dow_spec.match?(CRON_PATTERNS[:last_weekday_numeric])
226
+
227
+ match = dow_spec.match(CRON_PATTERNS[:last_weekday_name])
228
+ return dow_spec unless match
229
+
230
+ day_num = AWS_DAY_NAMES[match[:name].upcase] || match[:name]
231
+ "#{day_num}L"
232
+ end
233
+
234
+ # Normalize month field (e.g., "jan", "feb-mar", "apr/2")
235
+ def normalize_month_field(month)
236
+ return month if ['*', '?'].include?(month) || month =~ /^\d/
237
+
238
+ # Handle ranges like "jan-mar"
239
+ if month.include?('-')
240
+ parts = month.split('-')
241
+ start_num = month_abbr_to_num(parts[0])
242
+ end_num = month_abbr_to_num(parts[1])
243
+ return "#{start_num}-#{end_num}"
244
+ end
245
+
246
+ # Handle lists like "jan,mar,jun"
247
+ if month.include?(',')
248
+ parts = month.split(',').map { |m| month_abbr_to_num(m) }
249
+ return parts.join(',')
250
+ end
251
+
252
+ # Handle steps like "jan/2" and "*/2" - the wildcard base is not a name
253
+ if month.include?('/')
254
+ parts = month.split('/')
255
+ start = ['*', '?'].include?(parts[0]) ? parts[0] : month_abbr_to_num(parts[0])
256
+ return "#{start}/#{parts[1]}"
257
+ end
258
+
259
+ # Single month abbreviation
260
+ month_abbr_to_num(month).to_s
261
+ end
262
+
263
+ def weekday_only?(source)
264
+ tokens = normalize_source(source).split(/\s+/)
265
+ return false if tokens.length < 5
266
+
267
+ dom_idx = tokens.length == 5 ? 2 : 3
268
+ dow_idx = tokens.length == 5 ? 4 : 5
269
+
270
+ dom = tokens[dom_idx]
271
+ dow = tokens[dow_idx]
272
+
273
+ dom == '?' && dow != '?' && dow.exclude?('#') && dow.exclude?('L')
274
+ end
275
+ end
276
+ end
277
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AWSCronParser
4
+ module Helpers
5
+ module Numbers
6
+ # Convert number to ordinal string (1 => "1st", 2 => "2nd", etc.)
7
+ def ordinal(num)
8
+ num = num.to_i
9
+ case num % 100
10
+ when 11, 12, 13
11
+ "#{num}th"
12
+ else
13
+ case num % 10
14
+ when 1 then "#{num}st"
15
+ when 2 then "#{num}nd"
16
+ when 3 then "#{num}rd"
17
+ else "#{num}th"
18
+ end
19
+ end
20
+ end
21
+
22
+ # Convert number to ordinal word (1 => "first", 2 => "second", etc.)
23
+ def ordinal_word(num)
24
+ num = num.to_i
25
+ words = %w[zeroth first second third fourth fifth sixth seventh eighth ninth tenth
26
+ eleventh twelfth thirteenth fourteenth fifteenth sixteenth seventeenth
27
+ eighteenth nineteenth twentieth]
28
+ words[num] || ordinal(num).to_s
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AWSCronParser
4
+ module Helpers
5
+ def self.included(base)
6
+ [
7
+ AWSCronParser::Helpers::Calendar,
8
+ AWSCronParser::Helpers::Clock,
9
+ AWSCronParser::Helpers::CronExpression,
10
+ AWSCronParser::Helpers::Numbers
11
+ ].each do |m|
12
+ base.include(m)
13
+ base.extend(m)
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/object/blank'
4
+
5
+ module AWSCronParser
6
+ class Parser
7
+ include AWSCronParser::Helpers
8
+
9
+ def initialize(source, time_source = Time)
10
+ @original_source = source
11
+ @time_source = time_source
12
+
13
+ validate!
14
+
15
+ # Route to appropriate parser
16
+ @parser = if RATE_REGEX.match?(@original_source)
17
+ AWSCronParser::Parsers::AWSRate.new(@original_source, @time_source)
18
+ elsif AT_REGEX.match?(@original_source)
19
+ AWSCronParser::Parsers::AWSAt.new(@original_source, @time_source)
20
+ elsif AWS_CRON_REGEX.match?(@original_source) || @original_source.start_with?('@')
21
+ AWSCronParser::Parsers::AWSCron.new(@original_source, @time_source)
22
+ else
23
+ AWSCronParser::Parsers::QuartzCron.new(@original_source, @time_source)
24
+ end
25
+ end
26
+
27
+ def next(now = @time_source.now)
28
+ @parser.next(now)
29
+ rescue ZeroDivisionError => e
30
+ # Normalize low-level arithmetic errors from underlying parser into
31
+ # a user-facing ArgumentError so callers (and controllers) can
32
+ # return a proper 4xx validation response instead of crashing.
33
+ raise ArgumentError, "Invalid CRON expression: #{e.message}"
34
+ end
35
+
36
+ def last(now = @time_source.now)
37
+ if @parser.is_a?(AWSCronParser::Parsers::AWSCron) ||
38
+ @parser.is_a?(AWSCronParser::Parsers::AWSRate) ||
39
+ @parser.is_a?(AWSCronParser::Parsers::AWSAt)
40
+ raise ArgumentError, 'last() not supported for special patterns'
41
+ end
42
+
43
+ @parser.last(now)
44
+ end
45
+
46
+ # Get multiple next runs (shows unique occurrences for UI when appropriate)
47
+ # @param count [Integer] Number of next runs to calculate
48
+ # @param from [Time] Starting time (defaults to now)
49
+ # @return [Array<Time>] Array of next execution times
50
+ def next_runs(count = 5, from = @time_source.now)
51
+ # Always show all executions - the UI can handle pagination/filtering if needed
52
+ next_runs_all(count, from)
53
+ end
54
+
55
+ # Get ALL next runs (raw behavior - every single execution)
56
+ # @param count [Integer] Number of next runs to calculate
57
+ # @param from [Time] Starting time (defaults to now)
58
+ # @return [Array<Time>] Array of next execution times
59
+ def next_runs_all(count = 5, from = @time_source.now)
60
+ raise ArgumentError, 'count must be positive' if count <= 0
61
+
62
+ results = []
63
+ current = from
64
+
65
+ count.times do
66
+ current = @parser.next(current)
67
+ # A one-time at() schedule stops yielding once its instant has passed.
68
+ break if current.nil?
69
+
70
+ results << current
71
+ end
72
+
73
+ results
74
+ end
75
+
76
+ # Get unique dates (remove time component)
77
+ def next_unique_dates(count, from = @time_source.now)
78
+ seen_dates = Set.new
79
+ results = []
80
+ current = from
81
+
82
+ while results.length < count && results.length < 1000 # Safety limit
83
+ current = self.next(current)
84
+ break if current.nil?
85
+
86
+ date_key = current.to_date
87
+
88
+ next if seen_dates.include?(date_key)
89
+
90
+ seen_dates.add(date_key)
91
+ # Set to start of day for cleaner display
92
+ results << current.beginning_of_day
93
+ end
94
+
95
+ results
96
+ end
97
+
98
+ # Get unique hours (remove minute component)
99
+ def next_unique_hours(count, from = @time_source.now)
100
+ seen_hours = Set.new
101
+ results = []
102
+ current = from
103
+
104
+ while results.length < count && results.length < 1000 # Safety limit
105
+ current = self.next(current)
106
+ break if current.nil?
107
+
108
+ hour_key = [current.to_date, current.hour]
109
+
110
+ next if seen_hours.include?(hour_key)
111
+
112
+ seen_hours.add(hour_key)
113
+ # Set to start of hour for cleaner display
114
+ results << current.beginning_of_hour
115
+ end
116
+
117
+ results
118
+ end
119
+
120
+ def source
121
+ @parser.source
122
+ end
123
+
124
+ def expression
125
+ @original_source
126
+ end
127
+
128
+ def describe
129
+ Describer.describe(@original_source)
130
+ end
131
+
132
+ def validate!
133
+ Validator.validate(@original_source)
134
+ end
135
+
136
+ def validate
137
+ @errors = nil
138
+ Validator.validate(@original_source)
139
+ rescue ArgumentError => e
140
+ @errors = [e.message]
141
+ end
142
+
143
+ def valid?
144
+ validate
145
+ errors.empty?
146
+ end
147
+
148
+ def errors
149
+ @errors || []
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AWSCronParser
4
+ module Parsers
5
+ # AWS one-time schedule: at(yyyy-MM-ddThh:mm:ss). Fires exactly once.
6
+ class AWSAt < Base
7
+ attr_reader :at
8
+
9
+ def initialize(source, time_source = Time)
10
+ super
11
+
12
+ match = source.strip.match(AT_REGEX)
13
+ raise ArgumentError, "Invalid at format: #{source}" unless match
14
+
15
+ @at = zone.parse(match[:datetime].strip)
16
+ end
17
+
18
+ # nil once the instant has passed: a one-time schedule has no further runs.
19
+ def next(now = @time_source.now)
20
+ @at > ensure_time(now) ? @at : nil
21
+ end
22
+
23
+ private
24
+
25
+ # AWS at() carries no offset, so it is read in the caller's zone when one
26
+ # was given, and otherwise in Time.zone (UTC unless the host set it).
27
+ def zone
28
+ @time_source.is_a?(ActiveSupport::TimeZone) ? @time_source : ::Time.zone
29
+ end
30
+ end
31
+ end
32
+ end