chronic 0.4.1 → 0.5.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 (46) hide show
  1. data/.gemtest +0 -0
  2. data/.gitignore +5 -0
  3. data/.yardopts +3 -0
  4. data/HISTORY.md +36 -2
  5. data/Manifest.txt +6 -2
  6. data/README.md +8 -2
  7. data/Rakefile +9 -101
  8. data/chronic.gemspec +12 -78
  9. data/lib/chronic/chronic.rb +225 -91
  10. data/lib/chronic/grabber.rb +12 -3
  11. data/lib/chronic/handlers.rb +94 -198
  12. data/lib/chronic/mini_date.rb +6 -2
  13. data/lib/chronic/ordinal.rb +15 -4
  14. data/lib/chronic/pointer.rb +12 -3
  15. data/lib/chronic/repeater.rb +28 -9
  16. data/lib/chronic/repeaters/repeater_day.rb +0 -1
  17. data/lib/chronic/repeaters/repeater_day_name.rb +0 -1
  18. data/lib/chronic/repeaters/repeater_day_portion.rb +0 -1
  19. data/lib/chronic/repeaters/repeater_fortnight.rb +0 -1
  20. data/lib/chronic/repeaters/repeater_hour.rb +0 -1
  21. data/lib/chronic/repeaters/repeater_minute.rb +0 -1
  22. data/lib/chronic/repeaters/repeater_month.rb +13 -2
  23. data/lib/chronic/repeaters/repeater_month_name.rb +13 -21
  24. data/lib/chronic/repeaters/repeater_season.rb +0 -19
  25. data/lib/chronic/repeaters/repeater_season_name.rb +0 -2
  26. data/lib/chronic/repeaters/repeater_second.rb +0 -1
  27. data/lib/chronic/repeaters/repeater_time.rb +1 -2
  28. data/lib/chronic/repeaters/repeater_week.rb +0 -1
  29. data/lib/chronic/repeaters/repeater_weekday.rb +0 -1
  30. data/lib/chronic/repeaters/repeater_weekend.rb +0 -1
  31. data/lib/chronic/repeaters/repeater_year.rb +19 -8
  32. data/lib/chronic/scalar.rb +29 -1
  33. data/lib/chronic/season.rb +37 -0
  34. data/lib/chronic/separator.rb +24 -7
  35. data/lib/chronic/tag.rb +10 -1
  36. data/lib/chronic/time_zone.rb +12 -3
  37. data/lib/chronic/token.rb +14 -4
  38. data/lib/chronic.rb +62 -49
  39. data/test/test_Chronic.rb +52 -2
  40. data/test/test_RepeaterMonth.rb +4 -0
  41. data/test/test_RepeaterSeason.rb +40 -0
  42. data/test/test_RepeaterYear.rb +7 -0
  43. data/test/test_Time.rb +1 -1
  44. data/test/test_parsing.rb +159 -113
  45. metadata +31 -29
  46. data/benchmark/benchmark.rb +0 -13
@@ -11,76 +11,77 @@ 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.
51
- #
52
- # [<tt>:endian_precedence</tt>]
53
- # Array (defaults to <tt>[:middle, :little]</tt>)
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
54
46
  #
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 InvalidArgumentException, "#{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 InvalidArgumentException, "Invalid context, :past/:future only"
71
+ end
67
72
 
73
+ options[:text] = text
68
74
  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)
75
+ Chronic.now = options[:now]
73
76
 
74
77
  # tokenize words
75
- @tokens = tokenize(text, options)
78
+ tokens = tokenize(text, options)
76
79
 
77
80
  if Chronic.debug
78
- puts "+---------------------------------------------------"
79
- puts "| " + @tokens.to_s
80
- puts "+---------------------------------------------------"
81
+ puts "+#{'-' * 51}\n| #{tokens}\n+#{'-' * 51}"
81
82
  end
82
83
 
83
- span = tokens_to_span(@tokens, options)
84
+ span = tokens_to_span(tokens, options)
84
85
 
85
86
  if options[:guess]
86
87
  guess span
@@ -89,52 +90,67 @@ module Chronic
89
90
  end
90
91
  end
91
92
 
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
93
+ # Clean up the specified text ready for parsing
94
+ #
95
+ # Clean up the string by stripping unwanted characters, converting
96
+ # idioms to their canonical form, converting number words to numbers
97
+ # (three => 3), and converting ordinal words to numeric
95
98
  # 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
99
+ #
100
+ # @example
101
+ # Chronic.pre_normalize('first day in May')
102
+ # #=> "1st day in may"
103
+ #
104
+ # Chronic.pre_normalize('tomorrow after noon')
105
+ # #=> "next day future 12:00"
106
+ #
107
+ # Chronic.pre_normalize('one hundred and thirty six days from now')
108
+ # #=> "136 days future this second"
109
+ #
110
+ # @param [String] text The string to normalize
111
+ # @return [String] A new string ready for Chronic to parse
112
+ def pre_normalize(text)
113
+ text = text.to_s.downcase
114
+ text.gsub!(/['"\.]/, '')
115
+ text.gsub!(/,/,' ')
116
+ text.gsub!(/\bsecond (of|day|month|hour|minute|second)\b/, '2nd \1')
117
+ text = numericize_numbers(text)
118
+ text.gsub!(/ \-(\d{4})\b/, ' tzminus\1')
119
+ text.gsub!(/([\/\-\,\@])/) { ' ' + $1 + ' ' }
120
+ text.gsub!(/\b0(\d+:\d+\s*pm?\b)/, '\1')
121
+ text.gsub!(/\btoday\b/, 'this day')
122
+ text.gsub!(/\btomm?orr?ow\b/, 'next day')
123
+ text.gsub!(/\byesterday\b/, 'last day')
124
+ text.gsub!(/\bnoon\b/, '12:00')
125
+ text.gsub!(/\bmidnight\b/, '24:00')
126
+ text.gsub!(/\bbefore now\b/, 'past')
127
+ text.gsub!(/\bnow\b/, 'this second')
128
+ text.gsub!(/\b(ago|before)\b/, 'past')
129
+ text.gsub!(/\bthis past\b/, 'last')
130
+ text.gsub!(/\bthis last\b/, 'last')
131
+ text.gsub!(/\b(?:in|during) the (morning)\b/, '\1')
132
+ text.gsub!(/\b(?:in the|during the|at) (afternoon|evening|night)\b/, '\1')
133
+ text.gsub!(/\btonight\b/, 'this night')
134
+ text.gsub!(/\b\d+:?\d*[ap]\b/,'\0m')
135
+ text.gsub!(/(\d)([ap]m|oclock)\b/, '\1 \2')
136
+ text.gsub!(/\b(hence|after|from)\b/, 'future')
137
+ text
121
138
  end
122
139
 
123
- # Convert number words to numbers (three => 3)
124
- def numericize_numbers(text) #:nodoc:
140
+ # Convert number words to numbers (three => 3, fourth => 4th)
141
+ #
142
+ # @see Numerizer.numerize
143
+ # @param [String] text The string to convert
144
+ # @return [String] A new string with words converted to numbers
145
+ def numericize_numbers(text)
125
146
  Numerizer.numerize(text)
126
147
  end
127
148
 
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
149
  # Guess a specific time within the given span
137
- def guess(span) #:nodoc:
150
+ #
151
+ # @param [Span] span
152
+ # @return [Time]
153
+ def guess(span)
138
154
  return nil if span.nil?
139
155
  if span.width > 1
140
156
  span.begin + (span.width / 2)
@@ -142,16 +158,134 @@ module Chronic
142
158
  span.begin
143
159
  end
144
160
  end
161
+
162
+ # List of {Handler} definitions. See {parse} for a list of options this
163
+ # method accepts
164
+ #
165
+ # @see parse
166
+ # @return [Hash] A Hash of Handler definitions
167
+ def definitions(options={})
168
+ options[:endian_precedence] ||= [:middle, :little]
169
+
170
+ @definitions ||= {
171
+ :time => [
172
+ Handler.new([:repeater_time, :repeater_day_portion?], nil)
173
+ ],
174
+
175
+ :date => [
176
+ 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),
177
+ Handler.new([:repeater_month_name, :scalar_day, :scalar_year], :handle_rmn_sd_sy),
178
+ Handler.new([:repeater_month_name, :ordinal_day, :scalar_year], :handle_rmn_od_sy),
179
+ Handler.new([:repeater_month_name, :scalar_day, :scalar_year, :separator_at?, 'time?'], :handle_rmn_sd_sy),
180
+ Handler.new([:repeater_month_name, :ordinal_day, :scalar_year, :separator_at?, 'time?'], :handle_rmn_od_sy),
181
+ Handler.new([:repeater_month_name, :scalar_day, :separator_at?, 'time?'], :handle_rmn_sd),
182
+ Handler.new([:repeater_time, :repeater_day_portion?, :separator_on?, :repeater_month_name, :scalar_day], :handle_rmn_sd_on),
183
+ Handler.new([:repeater_month_name, :ordinal_day, :separator_at?, 'time?'], :handle_rmn_od),
184
+ Handler.new([:ordinal_day, :repeater_month_name, :scalar_year, :separator_at?, 'time?'], :handle_od_rmn_sy),
185
+ Handler.new([:ordinal_day, :repeater_month_name, :separator_at?, 'time?'], :handle_od_rmn),
186
+ Handler.new([:repeater_time, :repeater_day_portion?, :separator_on?, :repeater_month_name, :ordinal_day], :handle_rmn_od_on),
187
+ Handler.new([:repeater_month_name, :scalar_year], :handle_rmn_sy),
188
+ Handler.new([:scalar_day, :repeater_month_name, :scalar_year, :separator_at?, 'time?'], :handle_sd_rmn_sy),
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 InvalidArgumentException, "Unknown endian option '#{endian}'"
226
+ end
227
+
228
+ @definitions
229
+ end
230
+
231
+ private
232
+
233
+ def tokenize(text, options)
234
+ text = pre_normalize(text)
235
+ tokens = text.split(' ').map { |word| Token.new(word) }
236
+ [Repeater, Grabber, Pointer, Scalar, Ordinal, Separator, TimeZone].each do |tok|
237
+ tokens = tok.scan(tokens, options)
238
+ end
239
+ tokens.select { |token| token.tagged? }
240
+ end
241
+
242
+ def tokens_to_span(tokens, options)
243
+ definitions = definitions(options)
244
+
245
+ (definitions[:date] + definitions[:endian]).each do |handler|
246
+ if handler.match(tokens, definitions)
247
+ puts "-date" if Chronic.debug
248
+ good_tokens = tokens.select { |o| !o.get_tag Separator }
249
+ return Handlers.send(handler.handler_method, good_tokens, options)
250
+ end
251
+ end
252
+
253
+ definitions[:anchor].each do |handler|
254
+ if handler.match(tokens, definitions)
255
+ puts "-anchor" if Chronic.debug
256
+ good_tokens = tokens.select { |o| !o.get_tag Separator }
257
+ return Handlers.send(handler.handler_method, good_tokens, options)
258
+ end
259
+ end
260
+
261
+ definitions[:arrow].each do |handler|
262
+ if handler.match(tokens, definitions)
263
+ puts "-arrow" if Chronic.debug
264
+ good_tokens = tokens.reject { |o| o.get_tag(SeparatorAt) || o.get_tag(SeparatorSlashOrDash) || o.get_tag(SeparatorComma) }
265
+ return Handlers.send(handler.handler_method, good_tokens, options)
266
+ end
267
+ end
268
+
269
+ definitions[:narrow].each do |handler|
270
+ if handler.match(tokens, definitions)
271
+ puts "-narrow" if Chronic.debug
272
+ good_tokens = tokens.select { |o| !o.get_tag Separator }
273
+ return Handlers.send(handler.handler_method, tokens, options)
274
+ end
275
+ end
276
+
277
+ puts "-none" if Chronic.debug
278
+ return nil
279
+ end
280
+
145
281
  end
146
282
 
147
283
  # Internal exception
148
- class ChronicPain < Exception #:nodoc:
149
-
284
+ class ChronicPain < Exception
150
285
  end
151
286
 
152
287
  # This exception is raised if an invalid argument is provided to
153
288
  # any of Chronic's methods
154
289
  class InvalidArgumentException < Exception
155
-
156
290
  end
157
291
  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
  {