chronic 0.4.1 → 0.6.5

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 (64) hide show
  1. data/.gemtest +0 -0
  2. data/.gitignore +5 -0
  3. data/.yardopts +3 -0
  4. data/HISTORY.md +75 -2
  5. data/README.md +10 -5
  6. data/Rakefile +12 -106
  7. data/chronic.gemspec +12 -78
  8. data/lib/chronic/chronic.rb +271 -101
  9. data/lib/chronic/grabber.rb +12 -3
  10. data/lib/chronic/handler.rb +90 -0
  11. data/lib/chronic/handlers.rb +247 -295
  12. data/lib/chronic/mini_date.rb +15 -5
  13. data/lib/chronic/numerizer.rb +3 -2
  14. data/lib/chronic/ordinal.rb +15 -4
  15. data/lib/chronic/pointer.rb +13 -5
  16. data/lib/chronic/repeater.rb +31 -15
  17. data/lib/chronic/repeaters/repeater_day.rb +6 -7
  18. data/lib/chronic/repeaters/repeater_day_name.rb +1 -2
  19. data/lib/chronic/repeaters/repeater_day_portion.rb +12 -13
  20. data/lib/chronic/repeaters/repeater_fortnight.rb +2 -3
  21. data/lib/chronic/repeaters/repeater_hour.rb +7 -8
  22. data/lib/chronic/repeaters/repeater_minute.rb +6 -7
  23. data/lib/chronic/repeaters/repeater_month.rb +22 -11
  24. data/lib/chronic/repeaters/repeater_month_name.rb +16 -24
  25. data/lib/chronic/repeaters/repeater_season.rb +10 -29
  26. data/lib/chronic/repeaters/repeater_season_name.rb +2 -4
  27. data/lib/chronic/repeaters/repeater_second.rb +0 -1
  28. data/lib/chronic/repeaters/repeater_time.rb +19 -20
  29. data/lib/chronic/repeaters/repeater_week.rb +0 -1
  30. data/lib/chronic/repeaters/repeater_weekday.rb +1 -2
  31. data/lib/chronic/repeaters/repeater_weekend.rb +0 -1
  32. data/lib/chronic/repeaters/repeater_year.rb +29 -18
  33. data/lib/chronic/scalar.rb +29 -1
  34. data/lib/chronic/season.rb +37 -0
  35. data/lib/chronic/separator.rb +24 -7
  36. data/lib/chronic/tag.rb +12 -1
  37. data/lib/chronic/time_zone.rb +14 -5
  38. data/lib/chronic/token.rb +14 -4
  39. data/lib/chronic.rb +66 -89
  40. data/test/helper.rb +1 -1
  41. data/test/test_Chronic.rb +97 -3
  42. data/test/test_DaylightSavings.rb +1 -1
  43. data/test/test_Handler.rb +1 -6
  44. data/test/test_MiniDate.rb +3 -3
  45. data/test/test_Numerizer.rb +1 -1
  46. data/test/test_RepeaterDayName.rb +1 -1
  47. data/test/test_RepeaterFortnight.rb +1 -1
  48. data/test/test_RepeaterHour.rb +1 -1
  49. data/test/test_RepeaterMinute.rb +1 -1
  50. data/test/test_RepeaterMonth.rb +5 -1
  51. data/test/test_RepeaterMonthName.rb +1 -1
  52. data/test/test_RepeaterSeason.rb +40 -0
  53. data/test/test_RepeaterTime.rb +1 -1
  54. data/test/test_RepeaterWeek.rb +1 -1
  55. data/test/test_RepeaterWeekday.rb +1 -1
  56. data/test/test_RepeaterWeekend.rb +1 -1
  57. data/test/test_RepeaterYear.rb +8 -1
  58. data/test/test_Span.rb +1 -1
  59. data/test/test_Token.rb +1 -1
  60. data/test/test_parsing.rb +228 -117
  61. metadata +30 -32
  62. data/Manifest.txt +0 -59
  63. data/benchmark/benchmark.rb +0 -13
  64. data/test/test_Time.rb +0 -49
@@ -11,147 +11,317 @@ module Chronic
11
11
 
12
12
  class << self
13
13
 
14
- # Parses a string containing a natural language date or time. If the parser
15
- # can find a date or time, either a Time or Chronic::Span will be returned
16
- # (depending on the value of <tt>:guess</tt>). If no date or time can be found,
17
- # +nil+ will be returned.
14
+ # Parses a string containing a natural language date or time
18
15
  #
19
- # Options are:
16
+ # If the parser can find a date or time, either a Time or Chronic::Span
17
+ # will be returned (depending on the value of `:guess`). If no
18
+ # date or time can be found, `nil` will be returned
20
19
  #
21
- # [<tt>:context</tt>]
22
- # <tt>:past</tt> or <tt>:future</tt> (defaults to <tt>:future</tt>)
20
+ # @param [String] text The text to parse
23
21
  #
24
- # If your string represents a birthday, you can set <tt>:context</tt> to <tt>:past</tt>
25
- # and if an ambiguous string is given, it will assume it is in the
26
- # past. Specify <tt>:future</tt> or omit to set a future context.
22
+ # @option opts [Symbol] :context (:future)
23
+ # * If your string represents a birthday, you can set `:context` to
24
+ # `:past` and if an ambiguous string is given, it will assume it is
25
+ # in the past. Specify `:future` or omit to set a future context.
27
26
  #
28
- # [<tt>:now</tt>]
29
- # Time (defaults to Time.now)
27
+ # @option opts [Object] :now (Time.now)
28
+ # * By setting `:now` to a Time, all computations will be based off of
29
+ # that time instead of `Time.now`. If set to nil, Chronic will use
30
+ # `Time.now`
30
31
  #
31
- # By setting <tt>:now</tt> to a Time, all computations will be based off
32
- # of that time instead of Time.now. If set to nil, Chronic will use Time.now.
32
+ # @option opts [Boolean] :guess (true)
33
+ # * By default, the parser will guess a single point in time for the
34
+ # given date or time. If you'd rather have the entire time span
35
+ # returned, set `:guess` to `false` and a {Chronic::Span} will
36
+ # be returned
33
37
  #
34
- # [<tt>:guess</tt>]
35
- # +true+ or +false+ (defaults to +true+)
36
- #
37
- # By default, the parser will guess a single point in time for the
38
- # given date or time. If you'd rather have the entire time span returned,
39
- # set <tt>:guess</tt> to +false+ and a Chronic::Span will be returned.
40
- #
41
- # [<tt>:ambiguous_time_range</tt>]
42
- # Integer or <tt>:none</tt> (defaults to <tt>6</tt> (6am-6pm))
43
- #
44
- # If an Integer is given, ambiguous times (like 5:00) will be
38
+ # @option opts [Integer] :ambiguous_time_range (6)
39
+ # * If an Integer is given, ambiguous times (like 5:00) will be
45
40
  # assumed to be within the range of that time in the AM to that time
46
- # in the PM. For example, if you set it to <tt>7</tt>, then the parser will
47
- # look for the time between 7am and 7pm. In the case of 5:00, it would
48
- # assume that means 5:00pm. If <tt>:none</tt> is given, no assumption
49
- # will be made, and the first matching instance of that time will
50
- # be used.
41
+ # in the PM. For example, if you set it to `7`, then the parser
42
+ # will look for the time between 7am and 7pm. In the case of 5:00, it
43
+ # would assume that means 5:00pm. If `:none` is given, no
44
+ # assumption will be made, and the first matching instance of that
45
+ # time will be used
51
46
  #
52
- # [<tt>:endian_precedence</tt>]
53
- # Array (defaults to <tt>[:middle, :little]</tt>)
54
- #
55
- # By default, Chronic will parse "03/04/2011" as the fourth day
47
+ # @option opts [Array] :endian_precedence ([:middle, :little])
48
+ # * By default, Chronic will parse "03/04/2011" as the fourth day
56
49
  # of the third month. Alternatively you can tell Chronic to parse
57
50
  # this as the third day of the fourth month by altering the
58
- # <tt>:endian_precedence</tt> to <tt>[:little, :middle]</tt>.
59
- def parse(text, specified_options = {})
60
- @text = text
61
- options = DEFAULT_OPTIONS.merge specified_options
51
+ # `:endian_precedence` to `[:little, :middle]`
52
+ #
53
+ # @option opts [Integer] :ambiguous_year_future_bias (50)
54
+ # * When parsing two digit years (ie 79) unlike Rubys Time class,
55
+ # Chronic will attempt to assume the full year using this figure.
56
+ # Chronic will look x amount of years into the future and past. If
57
+ # the two digit year is `now + x years` it's assumed to be the
58
+ # future, `now - x years` is assumed to be the past
59
+ #
60
+ # @return [Time, Chronic::Span, nil]
61
+ def parse(text, opts={})
62
+ options = DEFAULT_OPTIONS.merge opts
62
63
 
63
64
  # ensure the specified options are valid
64
- (specified_options.keys-DEFAULT_OPTIONS.keys).each {|key| raise(InvalidArgumentException, "#{key} is not a valid option key.")}
65
-
66
- [:past, :future, :none].include?(options[:context]) || raise(InvalidArgumentException, "Invalid value ':#{options[:context]}' for :context specified. Valid values are :past and :future.")
65
+ (opts.keys - DEFAULT_OPTIONS.keys).each do |key|
66
+ raise ArgumentError, "#{key} is not a valid option key."
67
+ end
67
68
 
68
- options[:now] ||= Chronic.time_class.now
69
- @now = options[:now]
69
+ unless [:past, :future, :none].include?(options[:context])
70
+ raise ArgumentError, "Invalid context, :past/:future only"
71
+ end
70
72
 
71
- # put the text into a normal format to ease scanning
72
- text = pre_normalize(text)
73
+ options[:text] = text
74
+ Chronic.now = options[:now] || Chronic.time_class.now
73
75
 
74
76
  # tokenize words
75
- @tokens = tokenize(text, options)
77
+ tokens = tokenize(text, options)
76
78
 
77
79
  if Chronic.debug
78
- puts "+---------------------------------------------------"
79
- puts "| " + @tokens.to_s
80
- puts "+---------------------------------------------------"
80
+ puts "+#{'-' * 51}\n| #{tokens}\n+#{'-' * 51}"
81
81
  end
82
82
 
83
- span = tokens_to_span(@tokens, options)
83
+ span = tokens_to_span(tokens, options)
84
84
 
85
- if options[:guess]
86
- guess span
87
- else
88
- span
85
+ if span
86
+ options[:guess] ? guess(span) : span
89
87
  end
90
88
  end
91
89
 
92
- # Clean up the specified input text by stripping unwanted characters,
93
- # converting idioms to their canonical form, converting number words
94
- # to numbers (three => 3), and converting ordinal words to numeric
90
+ # Clean up the specified text ready for parsing
91
+ #
92
+ # Clean up the string by stripping unwanted characters, converting
93
+ # idioms to their canonical form, converting number words to numbers
94
+ # (three => 3), and converting ordinal words to numeric
95
95
  # ordinals (third => 3rd)
96
- def pre_normalize(text) #:nodoc:
97
- normalized_text = text.to_s.downcase
98
- normalized_text.gsub!(/['"\.,]/, '')
99
- normalized_text.gsub!(/\bsecond (of|day|month|hour|minute|second)\b/, '2nd \1')
100
- normalized_text = numericize_numbers(normalized_text)
101
- normalized_text.gsub!(/ \-(\d{4})\b/, ' tzminus\1')
102
- normalized_text.gsub!(/([\/\-\,\@])/) { ' ' + $1 + ' ' }
103
- normalized_text.gsub!(/\b0(\d+:\d+\s*pm?\b)/, '\1')
104
- normalized_text.gsub!(/\btoday\b/, 'this day')
105
- normalized_text.gsub!(/\btomm?orr?ow\b/, 'next day')
106
- normalized_text.gsub!(/\byesterday\b/, 'last day')
107
- normalized_text.gsub!(/\bnoon\b/, '12:00')
108
- normalized_text.gsub!(/\bmidnight\b/, '24:00')
109
- normalized_text.gsub!(/\bbefore now\b/, 'past')
110
- normalized_text.gsub!(/\bnow\b/, 'this second')
111
- normalized_text.gsub!(/\b(ago|before)\b/, 'past')
112
- normalized_text.gsub!(/\bthis past\b/, 'last')
113
- normalized_text.gsub!(/\bthis last\b/, 'last')
114
- normalized_text.gsub!(/\b(?:in|during) the (morning)\b/, '\1')
115
- normalized_text.gsub!(/\b(?:in the|during the|at) (afternoon|evening|night)\b/, '\1')
116
- normalized_text.gsub!(/\btonight\b/, 'this night')
117
- normalized_text.gsub!(/\b\d+:?\d*[ap]\b/,'\0m')
118
- normalized_text.gsub!(/(\d)([ap]m|oclock)\b/, '\1 \2')
119
- normalized_text.gsub!(/\b(hence|after|from)\b/, 'future')
120
- normalized_text
96
+ #
97
+ # @example
98
+ # Chronic.pre_normalize('first day in May')
99
+ # #=> "1st day in may"
100
+ #
101
+ # Chronic.pre_normalize('tomorrow after noon')
102
+ # #=> "next day future 12:00"
103
+ #
104
+ # Chronic.pre_normalize('one hundred and thirty six days from now')
105
+ # #=> "136 days future this second"
106
+ #
107
+ # @param [String] text The string to normalize
108
+ # @return [String] A new string ready for Chronic to parse
109
+ def pre_normalize(text)
110
+ text = text.to_s.downcase
111
+ text.gsub!(/['"\.]/, '')
112
+ text.gsub!(/,/, ' ')
113
+ text.gsub!(/\bsecond (of|day|month|hour|minute|second)\b/, '2nd \1')
114
+ text = Numerizer.numerize(text)
115
+ text.gsub!(/ \-(\d{4})\b/, ' tzminus\1')
116
+ text.gsub!(/([\/\-\,\@])/) { ' ' + $1 + ' ' }
117
+ text.gsub!(/(?:^|\s)0(\d+:\d+\s*pm?\b)/, '\1')
118
+ text.gsub!(/\btoday\b/, 'this day')
119
+ text.gsub!(/\btomm?orr?ow\b/, 'next day')
120
+ text.gsub!(/\byesterday\b/, 'last day')
121
+ text.gsub!(/\bnoon\b/, '12:00pm')
122
+ text.gsub!(/\bmidnight\b/, '24:00')
123
+ text.gsub!(/\bnow\b/, 'this second')
124
+ text.gsub!(/\b(?:ago|before(?: now)?)\b/, 'past')
125
+ text.gsub!(/\bthis (?:last|past)\b/, 'last')
126
+ text.gsub!(/\b(?:in|during) the (morning)\b/, '\1')
127
+ text.gsub!(/\b(?:in the|during the|at) (afternoon|evening|night)\b/, '\1')
128
+ text.gsub!(/\btonight\b/, 'this night')
129
+ text.gsub!(/\b\d+:?\d*[ap]\b/,'\0m')
130
+ text.gsub!(/(\d)([ap]m|oclock)\b/, '\1 \2')
131
+ text.gsub!(/\b(hence|after|from)\b/, 'future')
132
+ text
121
133
  end
122
134
 
123
- # Convert number words to numbers (three => 3)
124
- def numericize_numbers(text) #:nodoc:
135
+ # Convert number words to numbers (three => 3, fourth => 4th)
136
+ #
137
+ # @see Numerizer.numerize
138
+ # @param [String] text The string to convert
139
+ # @return [String] A new string with words converted to numbers
140
+ def numericize_numbers(text)
141
+ warn "Chronic.numericize_numbers will be deprecated in version 0.7.0. Please use Chronic::Numerizer.numerize instead"
125
142
  Numerizer.numerize(text)
126
143
  end
127
144
 
128
- def tokenize(text, options) #:nodoc:
129
- tokens = text.split(' ').map { |word| Token.new(word) }
130
- [Repeater, Grabber, Pointer, Scalar, Ordinal, Separator, TimeZone].each do |tok|
131
- tokens = tok.scan(tokens, options)
132
- end
133
- tokens.delete_if { |token| !token.tagged? }
134
- end
135
-
136
145
  # Guess a specific time within the given span
137
- def guess(span) #:nodoc:
138
- return nil if span.nil?
146
+ #
147
+ # @param [Span] span
148
+ # @return [Time]
149
+ def guess(span)
139
150
  if span.width > 1
140
151
  span.begin + (span.width / 2)
141
152
  else
142
153
  span.begin
143
154
  end
144
155
  end
145
- end
146
156
 
147
- # Internal exception
148
- class ChronicPain < Exception #:nodoc:
157
+ # List of {Handler} definitions. See {parse} for a list of options this
158
+ # method accepts
159
+ #
160
+ # @see parse
161
+ # @return [Hash] A Hash of Handler definitions
162
+ def definitions(options={})
163
+ options[:endian_precedence] ||= [:middle, :little]
149
164
 
150
- end
165
+ @definitions ||= {
166
+ :time => [
167
+ Handler.new([:repeater_time, :repeater_day_portion?], nil)
168
+ ],
169
+
170
+ :date => [
171
+ Handler.new([:repeater_day_name, :repeater_month_name, :scalar_day, :repeater_time, :separator_slash_or_dash?, :time_zone, :scalar_year], :handle_rdn_rmn_sd_t_tz_sy),
172
+ Handler.new([:repeater_day_name, :repeater_month_name, :scalar_day], :handle_rdn_rmn_sd),
173
+ Handler.new([:repeater_day_name, :repeater_month_name, :ordinal_day], :handle_rdn_rmn_od),
174
+ Handler.new([:scalar_year, :separator_slash_or_dash, :scalar_month, :separator_slash_or_dash, :scalar_day, :repeater_time, :time_zone], :handle_sy_sm_sd_t_tz),
175
+ Handler.new([:repeater_month_name, :scalar_day, :scalar_year], :handle_rmn_sd_sy),
176
+ Handler.new([:repeater_month_name, :ordinal_day, :scalar_year], :handle_rmn_od_sy),
177
+ Handler.new([:repeater_month_name, :scalar_day, :scalar_year, :separator_at?, 'time?'], :handle_rmn_sd_sy),
178
+ Handler.new([:repeater_month_name, :ordinal_day, :scalar_year, :separator_at?, 'time?'], :handle_rmn_od_sy),
179
+ Handler.new([:repeater_month_name, :scalar_day, :separator_at?, 'time?'], :handle_rmn_sd),
180
+ Handler.new([:repeater_time, :repeater_day_portion?, :separator_on?, :repeater_month_name, :scalar_day], :handle_rmn_sd_on),
181
+ Handler.new([:repeater_month_name, :ordinal_day, :separator_at?, 'time?'], :handle_rmn_od),
182
+ Handler.new([:ordinal_day, :repeater_month_name, :scalar_year, :separator_at?, 'time?'], :handle_od_rmn_sy),
183
+ Handler.new([:ordinal_day, :repeater_month_name, :separator_at?, 'time?'], :handle_od_rmn),
184
+ Handler.new([:scalar_year, :repeater_month_name, :ordinal_day], :handle_sy_rmn_od),
185
+ Handler.new([:repeater_time, :repeater_day_portion?, :separator_on?, :repeater_month_name, :ordinal_day], :handle_rmn_od_on),
186
+ Handler.new([:repeater_month_name, :scalar_year], :handle_rmn_sy),
187
+ Handler.new([:scalar_day, :repeater_month_name, :scalar_year, :separator_at?, 'time?'], :handle_sd_rmn_sy),
188
+ Handler.new([:scalar_day, :repeater_month_name, :separator_at?, 'time?'], :handle_sd_rmn),
189
+ Handler.new([:scalar_year, :separator_slash_or_dash, :scalar_month, :separator_slash_or_dash, :scalar_day, :separator_at?, 'time?'], :handle_sy_sm_sd),
190
+ Handler.new([:scalar_month, :separator_slash_or_dash, :scalar_year], :handle_sm_sy)
191
+ ],
192
+
193
+ # tonight at 7pm
194
+ :anchor => [
195
+ Handler.new([:grabber?, :repeater, :separator_at?, :repeater?, :repeater?], :handle_r),
196
+ Handler.new([:grabber?, :repeater, :repeater, :separator_at?, :repeater?, :repeater?], :handle_r),
197
+ Handler.new([:repeater, :grabber, :repeater], :handle_r_g_r)
198
+ ],
199
+
200
+ # 3 weeks from now, in 2 months
201
+ :arrow => [
202
+ Handler.new([:scalar, :repeater, :pointer], :handle_s_r_p),
203
+ Handler.new([:pointer, :scalar, :repeater], :handle_p_s_r),
204
+ Handler.new([:scalar, :repeater, :pointer, 'anchor'], :handle_s_r_p_a)
205
+ ],
206
+
207
+ # 3rd week in march
208
+ :narrow => [
209
+ Handler.new([:ordinal, :repeater, :separator_in, :repeater], :handle_o_r_s_r),
210
+ Handler.new([:ordinal, :repeater, :grabber, :repeater], :handle_o_r_g_r)
211
+ ]
212
+ }
213
+
214
+ endians = [
215
+ Handler.new([:scalar_month, :separator_slash_or_dash, :scalar_day, :separator_slash_or_dash, :scalar_year, :separator_at?, 'time?'], :handle_sm_sd_sy),
216
+ Handler.new([:scalar_day, :separator_slash_or_dash, :scalar_month, :separator_slash_or_dash, :scalar_year, :separator_at?, 'time?'], :handle_sd_sm_sy)
217
+ ]
218
+
219
+ case endian = Array(options[:endian_precedence]).first
220
+ when :little
221
+ @definitions[:endian] = endians.reverse
222
+ when :middle
223
+ @definitions[:endian] = endians
224
+ else
225
+ raise ArgumentError, "Unknown endian option '#{endian}'"
226
+ end
151
227
 
152
- # This exception is raised if an invalid argument is provided to
153
- # any of Chronic's methods
154
- class InvalidArgumentException < Exception
228
+ @definitions
229
+ end
230
+
231
+ # Construct a time Object
232
+ #
233
+ # @return [Time]
234
+ def construct(year, month = 1, day = 1, hour = 0, minute = 0, second = 0)
235
+ if second >= 60
236
+ minute += second / 60
237
+ second = second % 60
238
+ end
239
+
240
+ if minute >= 60
241
+ hour += minute / 60
242
+ minute = minute % 60
243
+ end
244
+
245
+ if hour >= 24
246
+ day += hour / 24
247
+ hour = hour % 24
248
+ end
249
+
250
+ # determine if there is a day overflow. this is complicated by our crappy calendar
251
+ # system (non-constant number of days per month)
252
+ day <= 56 || raise("day must be no more than 56 (makes month resolution easier)")
253
+ if day > 28
254
+ # no month ever has fewer than 28 days, so only do this if necessary
255
+ leap_year_month_days = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
256
+ common_year_month_days = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
257
+ days_this_month = Date.leap?(year) ? leap_year_month_days[month - 1] : common_year_month_days[month - 1]
258
+ if day > days_this_month
259
+ month += day / days_this_month
260
+ day = day % days_this_month
261
+ end
262
+ end
263
+
264
+ if month > 12
265
+ if month % 12 == 0
266
+ year += (month - 12) / 12
267
+ month = 12
268
+ else
269
+ year += month / 12
270
+ month = month % 12
271
+ end
272
+ end
273
+
274
+ Chronic.time_class.local(year, month, day, hour, minute, second)
275
+ end
155
276
 
277
+ private
278
+
279
+ def tokenize(text, options)
280
+ text = pre_normalize(text)
281
+ tokens = text.split(' ').map { |word| Token.new(word) }
282
+ [Repeater, Grabber, Pointer, Scalar, Ordinal, Separator, TimeZone].each do |tok|
283
+ tok.scan(tokens, options)
284
+ end
285
+ tokens.select { |token| token.tagged? }
286
+ end
287
+
288
+ def tokens_to_span(tokens, options)
289
+ definitions = definitions(options)
290
+
291
+ (definitions[:endian] + definitions[:date]).each do |handler|
292
+ if handler.match(tokens, definitions)
293
+ good_tokens = tokens.select { |o| !o.get_tag Separator }
294
+ return handler.invoke(:date, good_tokens, options)
295
+ end
296
+ end
297
+
298
+ definitions[:anchor].each do |handler|
299
+ if handler.match(tokens, definitions)
300
+ good_tokens = tokens.select { |o| !o.get_tag Separator }
301
+ return handler.invoke(:anchor, good_tokens, options)
302
+ end
303
+ end
304
+
305
+ definitions[:arrow].each do |handler|
306
+ if handler.match(tokens, definitions)
307
+ good_tokens = tokens.reject { |o| o.get_tag(SeparatorAt) || o.get_tag(SeparatorSlashOrDash) || o.get_tag(SeparatorComma) }
308
+ return handler.invoke(:arrow, good_tokens, options)
309
+ end
310
+ end
311
+
312
+ definitions[:narrow].each do |handler|
313
+ if handler.match(tokens, definitions)
314
+ return handler.invoke(:narrow, tokens, options)
315
+ end
316
+ end
317
+
318
+ puts "-none" if Chronic.debug
319
+ return nil
320
+ end
321
+
322
+ end
323
+
324
+ # Internal exception
325
+ class ChronicPain < Exception
156
326
  end
157
327
  end
@@ -1,11 +1,20 @@
1
1
  module Chronic
2
- class Grabber < Tag #:nodoc:
2
+ class Grabber < Tag
3
+
4
+ # Scan an Array of {Token}s and apply any necessary Grabber tags to
5
+ # each token
6
+ #
7
+ # @param [Array<Token>] tokens Array of tokens to scan
8
+ # @param [Hash] options Options specified in {Chronic.parse}
9
+ # @return [Array] list of tokens
3
10
  def self.scan(tokens, options)
4
- tokens.each_index do |i|
5
- if t = scan_for_all(tokens[i]) then tokens[i].tag(t); next end
11
+ tokens.each do |token|
12
+ if t = scan_for_all(token) then token.tag(t); next end
6
13
  end
7
14
  end
8
15
 
16
+ # @param [Token] token
17
+ # @return [Grabber, nil]
9
18
  def self.scan_for_all(token)
10
19
  scan_for token, self,
11
20
  {
@@ -0,0 +1,90 @@
1
+ module Chronic
2
+ class Handler
3
+
4
+ # @return [Array] A list of patterns
5
+ attr_reader :pattern
6
+
7
+ # @return [Symbol] The method which handles this list of patterns.
8
+ # This method should exist inside the {Handlers} module
9
+ attr_reader :handler_method
10
+
11
+ # @param [Array] pattern A list of patterns to match tokens against
12
+ # @param [Symbol] handler_method The method to be invoked when patterns
13
+ # are matched. This method should exist inside the {Handlers} module
14
+ def initialize(pattern, handler_method)
15
+ @pattern = pattern
16
+ @handler_method = handler_method
17
+ end
18
+
19
+ # @param [Array] tokens
20
+ # @param [Hash] definitions
21
+ # @return [Boolean]
22
+ # @see Chronic.tokens_to_span
23
+ def match(tokens, definitions)
24
+ token_index = 0
25
+
26
+ @pattern.each do |element|
27
+ name = element.to_s
28
+ optional = name[-1, 1] == '?'
29
+ name = name.chop if optional
30
+
31
+ case element
32
+ when Symbol
33
+ if tags_match?(name, tokens, token_index)
34
+ token_index += 1
35
+ next
36
+ else
37
+ if optional
38
+ next
39
+ else
40
+ return false
41
+ end
42
+ end
43
+ when String
44
+ return true if optional && token_index == tokens.size
45
+
46
+ if definitions.key?(name.to_sym)
47
+ sub_handlers = definitions[name.to_sym]
48
+ else
49
+ raise ChronicPain, "Invalid subset #{name} specified"
50
+ end
51
+
52
+ sub_handlers.each do |sub_handler|
53
+ return true if sub_handler.match(tokens[token_index..tokens.size], definitions)
54
+ end
55
+ else
56
+ raise ChronicPain, "Invalid match type: #{element.class}"
57
+ end
58
+ end
59
+
60
+ return false if token_index != tokens.size
61
+ return true
62
+ end
63
+
64
+ def invoke(type, tokens, options)
65
+ if Chronic.debug
66
+ puts "-#{type}"
67
+ puts "Handler: #{@handler_method}"
68
+ end
69
+
70
+ Handlers.send(@handler_method, tokens, options)
71
+ end
72
+
73
+ # @param [Handler] The handler to compare
74
+ # @return [Boolean] True if these handlers match
75
+ def ==(other)
76
+ @pattern == other.pattern
77
+ end
78
+
79
+ private
80
+
81
+ def tags_match?(name, tokens, token_index)
82
+ klass = Chronic.const_get(name.to_s.gsub(/(?:^|_)(.)/) { $1.upcase })
83
+
84
+ if tokens[token_index]
85
+ !tokens[token_index].tags.select { |o| o.kind_of?(klass) }.empty?
86
+ end
87
+ end
88
+
89
+ end
90
+ end