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 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
+ [![Tests](https://github.com/philiprehberger/rb-cron-parser/actions/workflows/ci.yml/badge.svg)](https://github.com/philiprehberger/rb-cron-parser/actions/workflows/ci.yml)
4
+ [![Gem Version](https://badge.fury.io/rb/philiprehberger-cron_parser.svg)](https://rubygems.org/gems/philiprehberger-cron_parser)
5
+ [![License](https://img.shields.io/github/license/philiprehberger/rb-cron-parser)](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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Philiprehberger
4
+ module CronParser
5
+ VERSION = '0.1.0'
6
+ end
7
+ 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: []