chronic 0.4.1 → 0.6.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 (63) hide show
  1. data/.gemtest +0 -0
  2. data/.gitignore +5 -0
  3. data/.yardopts +3 -0
  4. data/HISTORY.md +49 -2
  5. data/README.md +8 -2
  6. data/Rakefile +9 -101
  7. data/chronic.gemspec +12 -78
  8. data/lib/chronic/chronic.rb +260 -101
  9. data/lib/chronic/grabber.rb +12 -3
  10. data/lib/chronic/handler.rb +90 -0
  11. data/lib/chronic/handlers.rb +189 -301
  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 +8 -9
  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 +1 -2
  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 +12 -3
  38. data/lib/chronic/token.rb +14 -4
  39. data/lib/chronic.rb +66 -89
  40. data/test/test_Chronic.rb +97 -3
  41. data/test/test_DaylightSavings.rb +1 -1
  42. data/test/test_Handler.rb +1 -6
  43. data/test/test_MiniDate.rb +3 -3
  44. data/test/test_Numerizer.rb +1 -1
  45. data/test/test_RepeaterDayName.rb +1 -1
  46. data/test/test_RepeaterFortnight.rb +1 -1
  47. data/test/test_RepeaterHour.rb +1 -1
  48. data/test/test_RepeaterMinute.rb +1 -1
  49. data/test/test_RepeaterMonth.rb +5 -1
  50. data/test/test_RepeaterMonthName.rb +1 -1
  51. data/test/test_RepeaterSeason.rb +40 -0
  52. data/test/test_RepeaterTime.rb +1 -1
  53. data/test/test_RepeaterWeek.rb +1 -1
  54. data/test/test_RepeaterWeekday.rb +1 -1
  55. data/test/test_RepeaterWeekend.rb +1 -1
  56. data/test/test_RepeaterYear.rb +8 -1
  57. data/test/test_Span.rb +1 -1
  58. data/test/test_Token.rb +1 -1
  59. data/test/test_parsing.rb +167 -115
  60. metadata +32 -32
  61. data/Manifest.txt +0 -59
  62. data/benchmark/benchmark.rb +0 -13
  63. data/test/test_Time.rb +0 -49
@@ -11,147 +11,306 @@ 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
+ (opts.keys - DEFAULT_OPTIONS.keys).each do |key|
66
+ raise ArgumentError, "#{key} is not a valid option key."
67
+ end
65
68
 
66
- [:past, :future, :none].include?(options[:context]) || raise(InvalidArgumentException, "Invalid value ':#{options[:context]}' for :context specified. Valid values are :past and :future.")
69
+ unless [:past, :future, :none].include?(options[:context])
70
+ raise ArgumentError, "Invalid context, :past/:future only"
71
+ end
67
72
 
68
- options[:now] ||= Chronic.time_class.now
69
- @now = options[:now]
70
-
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!(/\b0(\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:00')
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([:scalar_year, :separator_slash_or_dash, :scalar_month, :separator_slash_or_dash, :scalar_day, :repeater_time, :time_zone], :handle_sy_sm_sd_t_tz),
173
+ Handler.new([:repeater_month_name, :scalar_day, :scalar_year], :handle_rmn_sd_sy),
174
+ Handler.new([:repeater_month_name, :ordinal_day, :scalar_year], :handle_rmn_od_sy),
175
+ Handler.new([:repeater_month_name, :scalar_day, :scalar_year, :separator_at?, 'time?'], :handle_rmn_sd_sy),
176
+ Handler.new([:repeater_month_name, :ordinal_day, :scalar_year, :separator_at?, 'time?'], :handle_rmn_od_sy),
177
+ Handler.new([:repeater_month_name, :scalar_day, :separator_at?, 'time?'], :handle_rmn_sd),
178
+ Handler.new([:repeater_time, :repeater_day_portion?, :separator_on?, :repeater_month_name, :scalar_day], :handle_rmn_sd_on),
179
+ Handler.new([:repeater_month_name, :ordinal_day, :separator_at?, 'time?'], :handle_rmn_od),
180
+ Handler.new([:ordinal_day, :repeater_month_name, :scalar_year, :separator_at?, 'time?'], :handle_od_rmn_sy),
181
+ Handler.new([:ordinal_day, :repeater_month_name, :separator_at?, 'time?'], :handle_od_rmn),
182
+ Handler.new([:repeater_time, :repeater_day_portion?, :separator_on?, :repeater_month_name, :ordinal_day], :handle_rmn_od_on),
183
+ Handler.new([:repeater_month_name, :scalar_year], :handle_rmn_sy),
184
+ Handler.new([:scalar_day, :repeater_month_name, :scalar_year, :separator_at?, 'time?'], :handle_sd_rmn_sy),
185
+ Handler.new([:scalar_year, :separator_slash_or_dash, :scalar_month, :separator_slash_or_dash, :scalar_day, :separator_at?, 'time?'], :handle_sy_sm_sd),
186
+ Handler.new([:scalar_month, :separator_slash_or_dash, :scalar_year], :handle_sm_sy)
187
+ ],
188
+
189
+ # tonight at 7pm
190
+ :anchor => [
191
+ Handler.new([:grabber?, :repeater, :separator_at?, :repeater?, :repeater?], :handle_r),
192
+ Handler.new([:grabber?, :repeater, :repeater, :separator_at?, :repeater?, :repeater?], :handle_r),
193
+ Handler.new([:repeater, :grabber, :repeater], :handle_r_g_r)
194
+ ],
195
+
196
+ # 3 weeks from now, in 2 months
197
+ :arrow => [
198
+ Handler.new([:scalar, :repeater, :pointer], :handle_s_r_p),
199
+ Handler.new([:pointer, :scalar, :repeater], :handle_p_s_r),
200
+ Handler.new([:scalar, :repeater, :pointer, 'anchor'], :handle_s_r_p_a)
201
+ ],
151
202
 
152
- # This exception is raised if an invalid argument is provided to
153
- # any of Chronic's methods
154
- class InvalidArgumentException < Exception
203
+ # 3rd week in march
204
+ :narrow => [
205
+ Handler.new([:ordinal, :repeater, :separator_in, :repeater], :handle_o_r_s_r),
206
+ Handler.new([:ordinal, :repeater, :grabber, :repeater], :handle_o_r_g_r)
207
+ ]
208
+ }
209
+
210
+ endians = [
211
+ Handler.new([:scalar_month, :separator_slash_or_dash, :scalar_day, :separator_slash_or_dash, :scalar_year, :separator_at?, 'time?'], :handle_sm_sd_sy),
212
+ Handler.new([:scalar_day, :separator_slash_or_dash, :scalar_month, :separator_slash_or_dash, :scalar_year, :separator_at?, 'time?'], :handle_sd_sm_sy)
213
+ ]
214
+
215
+ case endian = Array(options[:endian_precedence]).first
216
+ when :little
217
+ @definitions[:endian] = endians.reverse
218
+ when :middle
219
+ @definitions[:endian] = endians
220
+ else
221
+ raise ArgumentError, "Unknown endian option '#{endian}'"
222
+ end
155
223
 
224
+ @definitions
225
+ end
226
+
227
+ # Construct a time Object
228
+ #
229
+ # @return [Time]
230
+ def construct(year, month = 1, day = 1, hour = 0, minute = 0, second = 0)
231
+ if second >= 60
232
+ minute += second / 60
233
+ second = second % 60
234
+ end
235
+
236
+ if minute >= 60
237
+ hour += minute / 60
238
+ minute = minute % 60
239
+ end
240
+
241
+ if hour >= 24
242
+ day += hour / 24
243
+ hour = hour % 24
244
+ end
245
+
246
+ # determine if there is a day overflow. this is complicated by our crappy calendar
247
+ # system (non-constant number of days per month)
248
+ day <= 56 || raise("day must be no more than 56 (makes month resolution easier)")
249
+ if day > 28
250
+ # no month ever has fewer than 28 days, so only do this if necessary
251
+ leap_year_month_days = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
252
+ common_year_month_days = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
253
+ days_this_month = Date.leap?(year) ? leap_year_month_days[month - 1] : common_year_month_days[month - 1]
254
+ if day > days_this_month
255
+ month += day / days_this_month
256
+ day = day % days_this_month
257
+ end
258
+ end
259
+
260
+ if month > 12
261
+ if month % 12 == 0
262
+ year += (month - 12) / 12
263
+ month = 12
264
+ else
265
+ year += month / 12
266
+ month = month % 12
267
+ end
268
+ end
269
+
270
+ Chronic.time_class.local(year, month, day, hour, minute, second)
271
+ end
272
+
273
+ private
274
+
275
+ def tokenize(text, options)
276
+ text = pre_normalize(text)
277
+ tokens = text.split(' ').map { |word| Token.new(word) }
278
+ [Repeater, Grabber, Pointer, Scalar, Ordinal, Separator, TimeZone].each do |tok|
279
+ tok.scan(tokens, options)
280
+ end
281
+ tokens.select { |token| token.tagged? }
282
+ end
283
+
284
+ def tokens_to_span(tokens, options)
285
+ definitions = definitions(options)
286
+
287
+ definitions.each do |type, handlers|
288
+ handlers.each do |handler|
289
+ next unless handler.match(tokens, definitions)
290
+
291
+ good_tokens = case type
292
+ when :date, :endian, :anchor
293
+ tokens.reject { |o| o.get_tag Separator }
294
+ when :arrow
295
+ tokens.reject { |o| o.get_tag(SeparatorAt) || o.get_tag(SeparatorSlashOrDash) || o.get_tag(SeparatorComma) }
296
+ else
297
+ tokens
298
+ end
299
+
300
+ if handler.handler_method
301
+ return handler.invoke(type, good_tokens, options)
302
+ end
303
+
304
+ end
305
+ end
306
+
307
+ puts "-none" if Chronic.debug
308
+ return nil
309
+ end
310
+
311
+ end
312
+
313
+ # Internal exception
314
+ class ChronicPain < Exception
156
315
  end
157
316
  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