Skip to content

Commit

Permalink
Add a validating syntax parser.
Browse files Browse the repository at this point in the history
Closes square#46. Fixes square#47.

Prior to this commit the gem failed to validate the RRULE's syntax:

* If a keyword was misspelled, it would *silently* be ignored
* If a keyword appeared multiple times only the last occurance was used
* If the mutually exclusive keywords COUNT and UNTIL were used together
  then no exception was raised

```ruby
> rrule = RRule.parse('FREQ=DAILY;COUNT=3;COUNT=7;UNTIL=20200302;INTERVALLLLL=9')
 => #<RRule::Rule:0x000055db33885018 ...
> rrule.instance_variable_get(:@options)
 => {:interval=>1, :wkst=>1, :freq=>"DAILY", :count=>7, :until=>2020-03-02 00:00:00 +0100, ...
```

Therefore we add a validating parser with comprehensive error messages:

```ruby
> rrule = RRule.parse('FREQ=DAILY;COUNT=3;COUNT=7;UNTIL=20200302;INTERVALLLLL=9')
Traceback (most recent call last):
...
RRule::InvalidRRule (SyntaxError)

* unknown keyword 'INTERVALLLLL' at position 42:

        'FREQ=DAILY;COUNT=3;COUNT=7;UNTIL=20200302;INTERVALLLLL=9'
                                                   ^^^^^^^^^^^^
* keyword 'COUNT' appeared more than once at positions 11 and 19:

        'FREQ=DAILY;COUNT=3;COUNT=7;UNTIL=20200302;INTERVALLLLL=9'
                    ^^^^^   ^^^^^
* keywords 'COUNT' and 'UNTIL' are mutually exclusive at positions 11, 19 and 27:

        'FREQ=DAILY;COUNT=3;COUNT=7;UNTIL=20200302;INTERVALLLLL=9'
                    ^^^^^   ^^^^^   ^^^^^
```
  • Loading branch information
leoarnold committed Jul 4, 2022
1 parent cafd904 commit cb2ba5e
Show file tree
Hide file tree
Showing 11 changed files with 1,493 additions and 50 deletions.
30 changes: 29 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Description

rrule is a minimalist library for expanding RRULEs, with a goal of being fully compliant with [iCalendar spec](https://tools.ietf.org/html/rfc2445).
rrule is a minimalist library for expanding RRULEs, with a goal of being fully compliant with [iCalendar spec](https://tools.ietf.org/html/rfc5545).

## Examples

Expand Down Expand Up @@ -62,6 +62,34 @@ rrule.all
=> [2016-07-01 00:00:00 -0700, 2016-07-03 00:00:00 -0700]
```

### ActiveModel validator

In every project using `ActiveModel` the `RruleValidator` will automatically be loaded and can be used like this

```ruby
class MyEvent
include ActiveModel::Validations

attr_reader :recurrence_rule

def initialize(recurrence_rule)
@recurrence_rule = recurrence_rule
end

validates :recurrence_rule, rrule: true
end
```

or in a Rails app

```ruby
class MyEvent < ApplicationRecord
field :recurrence_rule, type: String

validates :recurrence_rule, rrule: true
end
```

## License

Copyright 2018 Square Inc.
Expand Down
38 changes: 38 additions & 0 deletions lib/rrule.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
require 'active_support/all'

module RRule
autoload :Parsers, 'rrule/parsers'
autoload :Rule, 'rrule/rule'
autoload :Context, 'rrule/context'
autoload :Weekday, 'rrule/weekday'
Expand All @@ -24,11 +25,48 @@ module RRule
autoload :AllOccurrences, 'rrule/generators/all_occurrences'
autoload :BySetPosition, 'rrule/generators/by_set_position'

KEYWORDS = %w[
FREQ
UNTIL
COUNT
INTERVAL
BYSECOND
BYMINUTE
BYHOUR
BYDAY
BYMONTHDAY
BYYEARDAY
BYWEEKNO
BYMONTH
BYSETPOS
WKST
].freeze

FREQUENCIES = %w[
SECONDLY
MINUTELY
HOURLY
DAILY
WEEKLY
MONTHLY
YEARLY
].freeze

WEEKDAYS = %w[SU MO TU WE TH FR SA].freeze

def self.parse(rrule, **options)
Rule.new(rrule, **options)
end

def self.valid?(rrule)
Parsers::RRule.parse!(rrule)

true
rescue InvalidRRule
false
end

class InvalidRRule < StandardError; end
end

autoload(:RruleValidator, 'rrule_validator')
2 changes: 1 addition & 1 deletion lib/rrule/frequencies/frequency.rb
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ def self.for_options(options)
when 'YEARLY'
Yearly
else
raise InvalidRRule, 'Valid FREQ value is required'
raise NotImplementedError, "Frequency '#{options[:freq]}' not implemented"
end
end

Expand Down
8 changes: 8 additions & 0 deletions lib/rrule/parsers.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# frozen_string_literal: true

module RRule
module Parsers
autoload :SyntaxError, 'rrule/parsers/syntax_error'
autoload :RRule, 'rrule/parsers/rrule'
end
end
279 changes: 279 additions & 0 deletions lib/rrule/parsers/rrule.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
# frozen_string_literal: true

module RRule
module Parsers
# A pragmatic parser for RFC5545 +RRULE+ (+RECUR+ value) expressions
# handwritten (for better readability and maintainability)
# in plain Ruby (therefore platform independent)
#
# @author Leo Arnold
# @see https://tools.ietf.org/html/rfc5545#section-3.3.10 RFC5545 iCalendar, section 3.3.10. Recurrence Rule
class RRule
class << self
def parse!(expression)
result, errors = parse(expression)

raise InvalidRRule, "SyntaxError\n\n#{errors.map(&:message).join("\n")}\n" if errors.any?

result
end

def parse(expression)
new(expression).parse
end

def valid?(expression)
_result, errors = parse(expression)

errors.none?
end
end

def initialize(expression)
@scanner = StringScanner.new(expression || '')
end

def parse
@scanner.reset
@keyword = nil
@errors = []
result = {}

until @scanner.eos?
@scanner.getch while @scanner.peek(1) == ';'

keyword_node = expect_keyword

if keyword_node
@keyword = keyword_node[:value]
result[keyword_node] = nil
else
skip_rule_part && next
end

value_node = expect_value

if value_node
result[keyword_node] = value_node
else
skip_rule_part && next
end
end

check_mandatory_keywords(result)
check_duplicate_keywords(result)
check_mutually_exclusive_keywords(result)

result = result.compact.transform_keys { |k| k[:value] }.transform_values { |v| v[:value] }

[result, @errors]
end

private

def check_mandatory_keywords(result)
syntax_error(summary: "missing keyword 'FREQ'", type: :missing_keyword) unless result.keys.any? { |k| k[:value] == 'FREQ' }
end

def check_duplicate_keywords(result)
result.keys.group_by { |keyword_node| keyword_node[:value] }.select { |_keyword, keyword_nodes| keyword_nodes.length > 1 }.each do |keyword, nodes|
syntax_error(summary: "keyword '#{keyword}' appeared more than once", type: :duplicate_keyword, markers: nodes.map { |node| (node[:position]..(node[:position] + keyword.length - 1)) })
end
end

def check_mutually_exclusive_keywords(result)
nodes = result.select { |k, _v| %w[COUNT UNTIL].include?(k[:value]) }

return if nodes.count < 2

syntax_error(summary: "keywords 'COUNT' and 'UNTIL' are mutually exclusive", type: :mutually_exclusive_keywords, markers: nodes.keys.map { |node| (node[:position]..(node[:position] + node[:value].length - 1)) })
end

def expect_keyword
position = @scanner.pos
value = ''

until @scanner.eos?
case @scanner.peek(1)
when /[A-Z]/i
value += @scanner.getch
when '='
@scanner.getch

break if KEYWORDS.include?(value.upcase)

if value.empty?
syntax_error(summary: 'missing keyword', type: :missing_keyword, markers: (@scanner.pos..@scanner.pos))
else
syntax_error(summary: "unknown keyword '#{value}'", type: :unknown_keyword, markers: ((@scanner.pos - value.length - 1)..(@scanner.pos - 2)))
end

return
when /[;]/
break if KEYWORDS.include?(value.upcase)

if value.empty?
syntax_error(summary: 'missing keyword', type: :missing_keyword, markers: (@scanner.pos..@scanner.pos))
else
syntax_error(summary: "unknown keyword '#{value}'", type: :unknown_keyword, markers: ((@scanner.pos - value.length - 1)..(@scanner.pos - 2)))
end

return
else
position = @scanner.pos

syntax_error(summary: "illegal character '#{@scanner.getch}'", type: :illegal_character, markers: (position..position))

return
end
end

{ position: position, value: value.upcase }
end

def expect_value
case @keyword
when 'FREQ'
expect_freq
when 'UNTIL'
expect_date_or_time
when 'COUNT', 'INTERVAL'
node = expect_integer

return if node.blank?

value = node[:value]

return node if value > 0

syntax_error(summary: "invalid '#{@keyword}' value '#{value}', expected positive integer", type: :bad_value, markers: (node[:position]..(node[:position] + value.to_s.length - 1)))
when 'BYSECOND', 'BYMINUTE', 'BYHOUR', 'BYWEEKNO', 'BYMONTH', 'BYMONTHDAY', 'BYYEARDAY', 'BYSETPOS'
expect_integer_list
when 'WKST'
expect_weekday
when 'BYDAY'
expect_weekday_list
else
syntax_error(summary: "unknown keyword '#{@keyword}'", type: :unknown_keyword, markers: ((@scanner.pos - value.length - 1)..(@scanner.pos - 2)))
end
end

def expect_date_or_time
position = @scanner.pos
value = @scanner.scan(/[^;]+/)

case value
when /^\d{8}$/
{ position: position, value: Date.strptime(value, '%Y%m%d') }
when /^\d{8}T\d{6}Z$/
{ position: position, value: Time.strptime(value, '%Y%m%dT%H%M%S%Z') }
else
syntax_error(summary: "invalid 'UNTIL' value '#{value}', expected date or date-time", type: :bad_value, markers: (position..(@scanner.pos - 1))) && return
end
end

def expect_integer_list
position = @scanner.pos
list = []

until @scanner.eos?
case @scanner.peek(1)
when /[+\-\d]/
value = @scanner.scan(/[^,;]+/)

list.push(value.to_i) && next if value =~ /^[+-]?\d+$/

syntax_error(summary: "invalid value '#{value}', expected integer", type: :bad_value, markers: (position..(@scanner.pos - 1)))
when /,/
@scanner.getch
when /;/
@scanner.getch

break
else
syntax_error(summary: 'Illegal character', type: :illegal_character, markers: (@scanner.pos..@scanner.pos))

return
end
end

{ position: position, value: list }
end

def expect_weekday
position = @scanner.pos
value = @scanner.scan(/[^;$]+/) || ''

return { position: position, value: value.upcase } if value.upcase =~ /^([+-]?[1-9]\d*)?(#{WEEKDAYS.join('|')})$/

syntax_error(summary: "invalid '#{@keyword}' value '#{value}'", type: :bad_value, markers: (position..(position + [0, value.length - 1].max)))
end

def expect_weekday_list
position = @scanner.pos
list = []

until @scanner.eos?
case @scanner.peek(1)
when /[+\-\w]/
value = @scanner.scan(/[^,;]+/)

list.push(value) && next if value =~ /^([+-]?[1-9]\d*)?(#{WEEKDAYS.join('|')})$/

syntax_error(summary: "invalid value '#{value}', expected integer", type: :bad_value, markers: (position..(@scanner.pos - 1)))
when /,/
@scanner.getch
when /;/
@scanner.getch

break
else
syntax_error(summary: 'Illegal character', type: :illegal_character, markers: (@scanner.pos..@scanner.pos))

return
end
end

{ position: position, value: list }
end

def expect_integer
position = @scanner.pos
value = @scanner.scan(/[^;$]+/) || ''

return { position: position, value: value.to_i } if value =~ /^[+\-]?\d+$/

syntax_error(summary: "invalid '#{@keyword}' value '#{value}'", type: :bad_value, markers: (position..(position + [0, value.length - 1].max)))
end

def expect_freq
position = @scanner.pos
value = @scanner.scan(/[^;$]+/) || ''

return { position: position, value: value.upcase } if FREQUENCIES.include?(value.upcase)

if value.empty?
syntax_error(summary: "missing '#{@keyword}' value", type: :missing_value, markers: (position..position))
else
syntax_error(summary: "invalid '#{@keyword}' value '#{value}'", type: :bad_value, markers: (position..(position + [0, value.length - 1].max)))
end
end

def skip_rule_part
@keyword = nil
@scanner.scan_until(/;|$/)
end

def syntax_error(summary:, type:, markers: [])
@errors << SyntaxError.new(
rrule: @scanner.string,
summary: summary,
type: type,
markers: markers
)

nil
end
end
end
end
Loading

0 comments on commit cb2ba5e

Please sign in to comment.