philiprehberger-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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +19 -0
- data/LICENSE +21 -0
- data/README.md +92 -0
- data/lib/philiprehberger/cron_parser/expression.rb +156 -0
- data/lib/philiprehberger/cron_parser/field.rb +77 -0
- data/lib/philiprehberger/cron_parser/version.rb +7 -0
- data/lib/philiprehberger/cron_parser.rb +20 -0
- metadata +56 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: d0fcde713c9e0d9e084402949a564bca9cf764b9fb1e60d7a1d065dd50a46662
|
|
4
|
+
data.tar.gz: 4c15cf6a91d629c565437079e02bb92ff1d7a33218ea042e770e759c5a7a1ef7
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: be56c15d393e12a8ffedccd297b696214400f871901559e3f38ae2a5c8356a0a136e5a3746db13890f06eef3b811ef9342ad37a827ce6127e75fb8ff25eedcd4
|
|
7
|
+
data.tar.gz: 68e86137912eebb04792836c6501a72e578628fdda9b48e5c8312f7ffd33d0af1c32dc2ab1634059d140b381f02129ae2a0f78e9f775036ec8414c392f3408b9
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this gem will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0] - 2026-03-22
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Initial release
|
|
14
|
+
- Standard 5-field cron expression parsing (minute hour day month weekday)
|
|
15
|
+
- Next and previous occurrence calculation
|
|
16
|
+
- Batch next-N occurrence lookup
|
|
17
|
+
- Time matching against cron patterns
|
|
18
|
+
- Human-readable cron description output
|
|
19
|
+
- Support for wildcards, ranges, steps, and lists
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 philiprehberger
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# philiprehberger-cron_parser
|
|
2
|
+
|
|
3
|
+
[](https://github.com/philiprehberger/rb-cron-parser/actions/workflows/ci.yml)
|
|
4
|
+
[](https://rubygems.org/gems/philiprehberger-cron_parser)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
Cron expression parser for calculating next and previous occurrences
|
|
8
|
+
|
|
9
|
+
## Requirements
|
|
10
|
+
|
|
11
|
+
- Ruby >= 3.1
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
Add to your Gemfile:
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
gem "philiprehberger-cron_parser"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Or install directly:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
gem install philiprehberger-cron_parser
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
require "philiprehberger/cron_parser"
|
|
31
|
+
|
|
32
|
+
cron = Philiprehberger::CronParser.new('0 9 * * 1-5')
|
|
33
|
+
cron.next(from: Time.now) # => next weekday at 9:00 AM
|
|
34
|
+
cron.prev(from: Time.now) # => previous weekday at 9:00 AM
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Next Occurrences
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
cron = Philiprehberger::CronParser.new('*/15 * * * *')
|
|
41
|
+
cron.next_n(5, from: Time.now) # => next 5 quarter-hour times
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Matching
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
cron = Philiprehberger::CronParser.new('0 9 * * *')
|
|
48
|
+
cron.matches?(Time.new(2026, 3, 22, 9, 0, 0)) # => true
|
|
49
|
+
cron.matches?(Time.new(2026, 3, 22, 10, 0, 0)) # => false
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Human-Readable Description
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
cron = Philiprehberger::CronParser.new('30 9 * * 1-5')
|
|
56
|
+
cron.human_readable # => "at minute 30, at hour 9, on weekday 1,2,3,4,5"
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Supported Syntax
|
|
60
|
+
|
|
61
|
+
Standard 5-field cron expressions (minute hour day month weekday):
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
Philiprehberger::CronParser.new('* * * * *') # every minute
|
|
65
|
+
Philiprehberger::CronParser.new('*/5 * * * *') # every 5 minutes
|
|
66
|
+
Philiprehberger::CronParser.new('0 9-17 * * *') # hourly 9am-5pm
|
|
67
|
+
Philiprehberger::CronParser.new('0 9,12,17 * * *') # specific hours
|
|
68
|
+
Philiprehberger::CronParser.new('0 0 1 * *') # first of month
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## API
|
|
72
|
+
|
|
73
|
+
| Method | Description |
|
|
74
|
+
|--------|-------------|
|
|
75
|
+
| `CronParser.new(expr)` | Parse a 5-field cron expression |
|
|
76
|
+
| `Expression#next(from:)` | Calculate the next matching time |
|
|
77
|
+
| `Expression#prev(from:)` | Calculate the previous matching time |
|
|
78
|
+
| `Expression#next_n(n, from:)` | Calculate the next N matching times |
|
|
79
|
+
| `Expression#matches?(time)` | Check if a time matches the expression |
|
|
80
|
+
| `Expression#human_readable` | Human-readable description of the expression |
|
|
81
|
+
|
|
82
|
+
## Development
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
bundle install
|
|
86
|
+
bundle exec rspec # Run tests
|
|
87
|
+
bundle exec rubocop # Check code style
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## License
|
|
91
|
+
|
|
92
|
+
MIT
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'field'
|
|
4
|
+
|
|
5
|
+
module Philiprehberger
|
|
6
|
+
module CronParser
|
|
7
|
+
# Represents a parsed 5-field cron expression
|
|
8
|
+
class Expression
|
|
9
|
+
FIELD_RANGES = {
|
|
10
|
+
minute: { min: 0, max: 59 },
|
|
11
|
+
hour: { min: 0, max: 23 },
|
|
12
|
+
day: { min: 1, max: 31 },
|
|
13
|
+
month: { min: 1, max: 12 },
|
|
14
|
+
weekday: { min: 0, max: 6 }
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
WEEKDAY_NAMES = { 'minute' => nil, 'hour' => nil, 'day' => nil, 'month' => nil,
|
|
18
|
+
'weekday' => nil }.freeze
|
|
19
|
+
|
|
20
|
+
HUMAN_LABELS = {
|
|
21
|
+
minute: 'minute',
|
|
22
|
+
hour: 'hour',
|
|
23
|
+
day: 'day of month',
|
|
24
|
+
month: 'month',
|
|
25
|
+
weekday: 'day of week'
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
# @return [String] the original expression
|
|
29
|
+
attr_reader :expression
|
|
30
|
+
|
|
31
|
+
# @param expr [String] a 5-field cron expression
|
|
32
|
+
# @raise [Error] if the expression is invalid
|
|
33
|
+
def initialize(expr)
|
|
34
|
+
@expression = expr.strip
|
|
35
|
+
parts = @expression.split(/\s+/)
|
|
36
|
+
raise Error, "Expected 5 fields, got #{parts.size}: #{@expression}" unless parts.size == 5
|
|
37
|
+
|
|
38
|
+
@fields = {}
|
|
39
|
+
FIELD_RANGES.each_with_index do |(name, range), index|
|
|
40
|
+
@fields[name] = Field.new(parts[index], **range)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Check if a given time matches this cron expression
|
|
45
|
+
#
|
|
46
|
+
# @param time [Time] the time to check
|
|
47
|
+
# @return [Boolean]
|
|
48
|
+
def matches?(time)
|
|
49
|
+
@fields[:minute].matches?(time.min) &&
|
|
50
|
+
@fields[:hour].matches?(time.hour) &&
|
|
51
|
+
@fields[:day].matches?(time.day) &&
|
|
52
|
+
@fields[:month].matches?(time.month) &&
|
|
53
|
+
@fields[:weekday].matches?(time.wday)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Calculate the next occurrence after the given time
|
|
57
|
+
#
|
|
58
|
+
# @param from [Time] the starting time
|
|
59
|
+
# @return [Time] the next matching time
|
|
60
|
+
# @raise [Error] if no match found within 4 years
|
|
61
|
+
def next(from: Time.now)
|
|
62
|
+
find_occurrence(from, direction: :forward)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Calculate the previous occurrence before the given time
|
|
66
|
+
#
|
|
67
|
+
# @param from [Time] the starting time
|
|
68
|
+
# @return [Time] the previous matching time
|
|
69
|
+
# @raise [Error] if no match found within 4 years
|
|
70
|
+
def prev(from: Time.now)
|
|
71
|
+
find_occurrence(from, direction: :backward)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Calculate the next N occurrences after the given time
|
|
75
|
+
#
|
|
76
|
+
# @param count [Integer] the number of occurrences to find
|
|
77
|
+
# @param from [Time] the starting time
|
|
78
|
+
# @return [Array<Time>] the next N matching times
|
|
79
|
+
def next_n(count, from: Time.now)
|
|
80
|
+
results = []
|
|
81
|
+
current = from
|
|
82
|
+
count.times do
|
|
83
|
+
current = self.next(from: current)
|
|
84
|
+
results << current
|
|
85
|
+
current += 60
|
|
86
|
+
end
|
|
87
|
+
results
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Return a human-readable description of the expression
|
|
91
|
+
#
|
|
92
|
+
# @return [String]
|
|
93
|
+
def human_readable
|
|
94
|
+
parts = []
|
|
95
|
+
parts << minute_description
|
|
96
|
+
parts << hour_description
|
|
97
|
+
parts << day_description
|
|
98
|
+
parts << month_description
|
|
99
|
+
parts << weekday_description
|
|
100
|
+
parts.compact.join(', ')
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def find_occurrence(from, direction:)
|
|
106
|
+
step = direction == :forward ? 60 : -60
|
|
107
|
+
current = round_to_minute(from) + step
|
|
108
|
+
limit = 4 * 365 * 24 * 60 # 4 years in minutes
|
|
109
|
+
|
|
110
|
+
limit.times do
|
|
111
|
+
return current if matches?(current)
|
|
112
|
+
|
|
113
|
+
current += step
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
raise Error, "No matching time found within 4 years"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def round_to_minute(time)
|
|
120
|
+
Time.new(time.year, time.month, time.day, time.hour, time.min, 0, time.utc_offset)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def minute_description
|
|
124
|
+
describe_field(:minute, 'every minute', 'at minute')
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def hour_description
|
|
128
|
+
describe_field(:hour, nil, 'at hour')
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def day_description
|
|
132
|
+
describe_field(:day, nil, 'on day')
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def month_description
|
|
136
|
+
describe_field(:month, nil, 'in month')
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def weekday_description
|
|
140
|
+
describe_field(:weekday, nil, 'on weekday')
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def describe_field(name, wildcard_text, prefix)
|
|
144
|
+
field = @fields[name]
|
|
145
|
+
range = FIELD_RANGES[name]
|
|
146
|
+
all_values = (range[:min]..range[:max]).to_a
|
|
147
|
+
|
|
148
|
+
if field.values == all_values
|
|
149
|
+
wildcard_text
|
|
150
|
+
else
|
|
151
|
+
"#{prefix} #{field.values.join(',')}"
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Philiprehberger
|
|
4
|
+
module CronParser
|
|
5
|
+
# Parses a single cron field (minute, hour, day, month, weekday)
|
|
6
|
+
class Field
|
|
7
|
+
# @return [Array<Integer>] the expanded set of valid values
|
|
8
|
+
attr_reader :values
|
|
9
|
+
|
|
10
|
+
# @param expr [String] the field expression (e.g. "*/5", "1,3,5", "1-10")
|
|
11
|
+
# @param min [Integer] the minimum valid value
|
|
12
|
+
# @param max [Integer] the maximum valid value
|
|
13
|
+
# @raise [Error] if the expression is invalid
|
|
14
|
+
def initialize(expr, min:, max:)
|
|
15
|
+
@min = min
|
|
16
|
+
@max = max
|
|
17
|
+
@values = parse(expr).sort.freeze
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Check if a value matches this field
|
|
21
|
+
#
|
|
22
|
+
# @param value [Integer] the value to check
|
|
23
|
+
# @return [Boolean]
|
|
24
|
+
def matches?(value)
|
|
25
|
+
@values.include?(value)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def parse(expr)
|
|
31
|
+
result = []
|
|
32
|
+
expr.split(',').each do |part|
|
|
33
|
+
result.concat(parse_part(part.strip))
|
|
34
|
+
end
|
|
35
|
+
result.uniq
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def parse_part(part)
|
|
39
|
+
case part
|
|
40
|
+
when '*'
|
|
41
|
+
(@min..@max).to_a
|
|
42
|
+
when /\A\*\/(\d+)\z/
|
|
43
|
+
step = Regexp.last_match(1).to_i
|
|
44
|
+
raise Error, "Invalid step: #{step}" if step.zero?
|
|
45
|
+
|
|
46
|
+
(@min..@max).step(step).to_a
|
|
47
|
+
when /\A(\d+)-(\d+)(?:\/(\d+))?\z/
|
|
48
|
+
range_start = Regexp.last_match(1).to_i
|
|
49
|
+
range_end = Regexp.last_match(2).to_i
|
|
50
|
+
step = Regexp.last_match(3)&.to_i || 1
|
|
51
|
+
validate_range!(range_start, range_end)
|
|
52
|
+
raise Error, "Invalid step: #{step}" if step.zero?
|
|
53
|
+
|
|
54
|
+
(range_start..range_end).step(step).to_a
|
|
55
|
+
when /\A\d+\z/
|
|
56
|
+
value = part.to_i
|
|
57
|
+
validate_value!(value)
|
|
58
|
+
[value]
|
|
59
|
+
else
|
|
60
|
+
raise Error, "Invalid cron field expression: #{part}"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def validate_range!(range_start, range_end)
|
|
65
|
+
validate_value!(range_start)
|
|
66
|
+
validate_value!(range_end)
|
|
67
|
+
raise Error, "Invalid range: #{range_start}-#{range_end}" if range_start > range_end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def validate_value!(value)
|
|
71
|
+
return if value >= @min && value <= @max
|
|
72
|
+
|
|
73
|
+
raise Error, "Value #{value} out of range (#{@min}-#{@max})"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'cron_parser/version'
|
|
4
|
+
require_relative 'cron_parser/field'
|
|
5
|
+
require_relative 'cron_parser/expression'
|
|
6
|
+
|
|
7
|
+
module Philiprehberger
|
|
8
|
+
module CronParser
|
|
9
|
+
class Error < StandardError; end
|
|
10
|
+
|
|
11
|
+
# Parse a cron expression and return an Expression instance
|
|
12
|
+
#
|
|
13
|
+
# @param expr [String] a 5-field cron expression (minute hour day month weekday)
|
|
14
|
+
# @return [Expression] the parsed expression
|
|
15
|
+
# @raise [Error] if the expression is invalid
|
|
16
|
+
def self.new(expr)
|
|
17
|
+
Expression.new(expr)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: philiprehberger-cron_parser
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Philip Rehberger
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-03-22 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description: Parse standard 5-field cron expressions and calculate next/previous occurrences,
|
|
14
|
+
match times against patterns, and generate human-readable descriptions.
|
|
15
|
+
email:
|
|
16
|
+
- me@philiprehberger.com
|
|
17
|
+
executables: []
|
|
18
|
+
extensions: []
|
|
19
|
+
extra_rdoc_files: []
|
|
20
|
+
files:
|
|
21
|
+
- CHANGELOG.md
|
|
22
|
+
- LICENSE
|
|
23
|
+
- README.md
|
|
24
|
+
- lib/philiprehberger/cron_parser.rb
|
|
25
|
+
- lib/philiprehberger/cron_parser/expression.rb
|
|
26
|
+
- lib/philiprehberger/cron_parser/field.rb
|
|
27
|
+
- lib/philiprehberger/cron_parser/version.rb
|
|
28
|
+
homepage: https://github.com/philiprehberger/rb-cron-parser
|
|
29
|
+
licenses:
|
|
30
|
+
- MIT
|
|
31
|
+
metadata:
|
|
32
|
+
homepage_uri: https://github.com/philiprehberger/rb-cron-parser
|
|
33
|
+
source_code_uri: https://github.com/philiprehberger/rb-cron-parser
|
|
34
|
+
changelog_uri: https://github.com/philiprehberger/rb-cron-parser/blob/main/CHANGELOG.md
|
|
35
|
+
bug_tracker_uri: https://github.com/philiprehberger/rb-cron-parser/issues
|
|
36
|
+
rubygems_mfa_required: 'true'
|
|
37
|
+
post_install_message:
|
|
38
|
+
rdoc_options: []
|
|
39
|
+
require_paths:
|
|
40
|
+
- lib
|
|
41
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
42
|
+
requirements:
|
|
43
|
+
- - ">="
|
|
44
|
+
- !ruby/object:Gem::Version
|
|
45
|
+
version: 3.1.0
|
|
46
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
47
|
+
requirements:
|
|
48
|
+
- - ">="
|
|
49
|
+
- !ruby/object:Gem::Version
|
|
50
|
+
version: '0'
|
|
51
|
+
requirements: []
|
|
52
|
+
rubygems_version: 3.5.22
|
|
53
|
+
signing_key:
|
|
54
|
+
specification_version: 4
|
|
55
|
+
summary: Cron expression parser for calculating next and previous occurrences
|
|
56
|
+
test_files: []
|