├── .gitignore
├── lib
├── timeliness
│ ├── version.rb
│ ├── core_ext.rb
│ ├── core_ext
│ │ └── string.rb
│ ├── configuration.rb
│ ├── helpers.rb
│ ├── format_set.rb
│ ├── format.rb
│ ├── parser.rb
│ └── definitions.rb
└── timeliness.rb
├── .rspec
├── Gemfile
├── gemfiles
├── activesupport_5_2.gemfile
├── activesupport_6_0.gemfile
├── activesupport_6_1.gemfile
├── activesupport_7_0.gemfile
├── activesupport_7_2.gemfile
├── activesupport_8_0.gemfile
├── activesupport_7_0.gemfile.lock
├── activesupport_5_2.gemfile.lock
├── activesupport_6_1.gemfile.lock
├── activesupport_6_0.gemfile.lock
├── activesupport_7_2.gemfile.lock
└── activesupport_8_0.gemfile.lock
├── Appraisals
├── Rakefile
├── spec
├── timeliness_helper.rb
├── timeliness
│ ├── core_ext
│ │ └── string_spec.rb
│ ├── helpers_spec.rb
│ ├── definitions_spec.rb
│ ├── format_set_spec.rb
│ ├── format_spec.rb
│ └── parser_spec.rb
└── spec_helper.rb
├── LICENSE
├── .github
└── workflows
│ └── ci.yml
├── timeliness.gemspec
├── CHANGELOG.rdoc
├── benchmark.rb
└── README.rdoc
/.gitignore:
--------------------------------------------------------------------------------
1 | pkg/*
2 | .bundle/
3 | vendor/bundle
4 | Gemfile.lock
5 | .byebug_history
6 |
--------------------------------------------------------------------------------
/lib/timeliness/version.rb:
--------------------------------------------------------------------------------
1 | module Timeliness
2 | VERSION = '0.5.3'.freeze
3 | end
4 |
--------------------------------------------------------------------------------
/.rspec:
--------------------------------------------------------------------------------
1 | --color
2 | --require debug
3 | --require spec_helper
4 | --require timeliness_helper
5 |
--------------------------------------------------------------------------------
/lib/timeliness/core_ext.rb:
--------------------------------------------------------------------------------
1 | require 'timeliness/core_ext/string'
2 |
3 | module Timeliness
4 | module CoreExt
5 | end
6 | end
7 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | source 'http://rubygems.org'
2 |
3 | gemspec
4 |
5 | gem 'appraisal'
6 | gem 'activesupport', '~> 7.2.0'
7 | gem 'debug', platforms: %i[mri mingw x64_mingw]
8 | gem 'memory_profiler'
9 |
--------------------------------------------------------------------------------
/gemfiles/activesupport_5_2.gemfile:
--------------------------------------------------------------------------------
1 | # This file was generated by Appraisal
2 |
3 | source "http://rubygems.org"
4 |
5 | gem "appraisal"
6 | gem "activesupport", "~> 5.2.0"
7 | gem "debug", platforms: [:mri, :mingw, :x64_mingw]
8 | gem "memory_profiler"
9 |
10 | gemspec path: "../"
11 |
--------------------------------------------------------------------------------
/gemfiles/activesupport_6_0.gemfile:
--------------------------------------------------------------------------------
1 | # This file was generated by Appraisal
2 |
3 | source "http://rubygems.org"
4 |
5 | gem "appraisal"
6 | gem "activesupport", "~> 6.0.0"
7 | gem "debug", platforms: [:mri, :mingw, :x64_mingw]
8 | gem "memory_profiler"
9 |
10 | gemspec path: "../"
11 |
--------------------------------------------------------------------------------
/gemfiles/activesupport_6_1.gemfile:
--------------------------------------------------------------------------------
1 | # This file was generated by Appraisal
2 |
3 | source "http://rubygems.org"
4 |
5 | gem "appraisal"
6 | gem "activesupport", "~> 6.1.0"
7 | gem "debug", platforms: [:mri, :mingw, :x64_mingw]
8 | gem "memory_profiler"
9 |
10 | gemspec path: "../"
11 |
--------------------------------------------------------------------------------
/gemfiles/activesupport_7_0.gemfile:
--------------------------------------------------------------------------------
1 | # This file was generated by Appraisal
2 |
3 | source "http://rubygems.org"
4 |
5 | gem "appraisal"
6 | gem "activesupport", "~> 7.0.0"
7 | gem "debug", platforms: [:mri, :mingw, :x64_mingw]
8 | gem "memory_profiler"
9 |
10 | gemspec path: "../"
11 |
--------------------------------------------------------------------------------
/gemfiles/activesupport_7_2.gemfile:
--------------------------------------------------------------------------------
1 | # This file was generated by Appraisal
2 |
3 | source "http://rubygems.org"
4 |
5 | gem "appraisal"
6 | gem "activesupport", "~> 7.2.0"
7 | gem "debug", platforms: [:mri, :mingw, :x64_mingw]
8 | gem "memory_profiler"
9 |
10 | gemspec path: "../"
11 |
--------------------------------------------------------------------------------
/gemfiles/activesupport_8_0.gemfile:
--------------------------------------------------------------------------------
1 | # This file was generated by Appraisal
2 |
3 | source "http://rubygems.org"
4 |
5 | gem "appraisal"
6 | gem "activesupport", "~> 8.0.0"
7 | gem "debug", platforms: [:mri, :mingw, :x64_mingw]
8 | gem "memory_profiler"
9 |
10 | gemspec path: "../"
11 |
--------------------------------------------------------------------------------
/Appraisals:
--------------------------------------------------------------------------------
1 | appraise "activesupport_5_2" do
2 | gem "activesupport", "~> 5.2.0"
3 | end
4 |
5 | appraise "activesupport_6_0" do
6 | gem "activesupport", "~> 6.0.0"
7 | end
8 |
9 | appraise "activesupport_6_1" do
10 | gem "activesupport", "~> 6.1.0"
11 | end
12 |
13 | appraise "activesupport_7_0" do
14 | gem "activesupport", "~> 7.0.0"
15 | end
16 |
17 | appraise "activesupport_7_2" do
18 | gem "activesupport", "~> 7.1.0"
19 | end
20 |
21 | appraise "activesupport_7_2" do
22 | gem "activesupport", "~> 7.2.0"
23 | end
24 |
25 | appraise "activesupport_8_0" do
26 | gem "activesupport", "~> 8.0.0"
27 | end
28 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | require 'bundler'
2 | Bundler::GemHelper.install_tasks
3 |
4 | require 'rdoc/task'
5 | require 'rspec/core/rake_task'
6 |
7 | desc "Run specs"
8 | RSpec::Core::RakeTask.new(:spec)
9 |
10 | desc "Generate code coverage"
11 | RSpec::Core::RakeTask.new(:coverage) do |t|
12 | t.rcov = true
13 | t.rcov_opts = ['--exclude', 'spec']
14 | end
15 |
16 | desc 'Generate documentation for plugin.'
17 | Rake::RDocTask.new(:rdoc) do |rdoc|
18 | rdoc.main = 'README.rdoc'
19 | rdoc.rdoc_dir = 'rdoc'
20 | rdoc.title = 'Timeliness'
21 | rdoc.options << '--line-numbers'
22 | rdoc.rdoc_files.include('README.rdoc')
23 | rdoc.rdoc_files.include('lib/**/*.rb')
24 | end
25 |
26 | desc 'Default: run specs.'
27 | task default: :spec
28 |
--------------------------------------------------------------------------------
/lib/timeliness/core_ext/string.rb:
--------------------------------------------------------------------------------
1 | class String
2 |
3 | # Form can be either :utc (default) or :local.
4 | def to_time(form = :utc)
5 | return nil if self.blank?
6 | Timeliness::Parser.parse(self, :datetime, zone: form)
7 | end
8 |
9 | def to_date
10 | return nil if self.blank?
11 | values = Timeliness::Parser._parse(self, :date).map { |arg| arg || 0 }
12 | ::Date.new(*values[0..2])
13 | end
14 |
15 | def to_datetime
16 | return nil if self.blank?
17 | values = Timeliness::Parser._parse(self, :datetime).map { |arg| arg || 0 }
18 | values[7] = values[7]/24.hours.to_f if values[7] != 0
19 | values[5] += Rational(values.delete_at(6), 1000000)
20 | ::DateTime.civil(*values)
21 | end
22 | end
23 |
--------------------------------------------------------------------------------
/lib/timeliness.rb:
--------------------------------------------------------------------------------
1 | require 'date'
2 | require 'forwardable'
3 |
4 | require 'timeliness/configuration'
5 | require 'timeliness/helpers'
6 | require 'timeliness/definitions'
7 | require 'timeliness/format'
8 | require 'timeliness/format_set'
9 | require 'timeliness/parser'
10 | require 'timeliness/version'
11 |
12 | module Timeliness
13 | class << self
14 | extend Forwardable
15 | def_delegators Parser, :parse, :_parse
16 | def_delegators Definitions, :add_formats, :remove_formats, :use_us_formats, :use_euro_formats
17 | attr_accessor :configuration
18 |
19 | def_delegators :configuration, :default_timezone, :date_for_time_type, :ambiguous_date_format, :ambiguous_year_threshold
20 | def_delegators :configuration, :default_timezone=, :date_for_time_type=, :ambiguous_date_format=, :ambiguous_year_threshold=
21 | end
22 |
23 | def self.configuration
24 | @configuration ||= Configuration.new
25 | end
26 |
27 | def self.configure
28 | yield(configuration)
29 | end
30 | end
31 |
32 | Timeliness::Definitions.compile_formats
33 |
--------------------------------------------------------------------------------
/spec/timeliness_helper.rb:
--------------------------------------------------------------------------------
1 | require 'active_support'
2 | require 'active_support/time'
3 | require 'active_support/core_ext/object'
4 | require 'timecop'
5 | require 'timeliness'
6 | require 'timeliness/core_ext'
7 |
8 | module TimelinessHelpers
9 | def parser
10 | Timeliness::Parser
11 | end
12 |
13 | def definitions
14 | Timeliness::Definitions
15 | end
16 |
17 | def parse(value, type=nil, **args)
18 | Timeliness::Parser.parse(value, type, **args)
19 | end
20 |
21 | def current_date(options={})
22 | Timeliness::Parser.send(:current_date, options)
23 | end
24 |
25 | def should_parse(value, type=nil, **args)
26 | expect(Timeliness::Parser.parse(value, type, **args)).not_to be_nil
27 | end
28 |
29 | def should_not_parse(value, type=nil, **args)
30 | expect(Timeliness::Parser.parse(value, type, **args)).to be_nil
31 | end
32 | end
33 |
34 | I18n.available_locales = ['en', 'es']
35 |
36 | RSpec.configure do |c|
37 | c.mock_with :rspec
38 | c.include TimelinessHelpers
39 |
40 | c.after do
41 | Timeliness.configuration = Timeliness::Configuration.new
42 | end
43 | end
44 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2010-2021 Adam Meehan
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining
4 | a copy of this software and associated documentation files (the
5 | "Software"), to deal in the Software without restriction, including
6 | without limitation the rights to use, copy, modify, merge, publish,
7 | distribute, sublicense, and/or sell copies of the Software, and to
8 | permit persons to whom the Software is furnished to do so, subject to
9 | the following conditions:
10 |
11 | The above copyright notice and this permission notice shall be
12 | included in all copies or substantial portions of the Software.
13 |
14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21 |
--------------------------------------------------------------------------------
/lib/timeliness/configuration.rb:
--------------------------------------------------------------------------------
1 | module Timeliness
2 | class Configuration
3 | # Default timezone. Options:
4 | # - :local (default)
5 | # - :utc
6 | #
7 | # If ActiveSupport loaded, also
8 | # - :current
9 | # - 'Zone name'
10 | #
11 | attr_accessor :default_timezone
12 |
13 | # Set the default date part for a time type values.
14 | #
15 | attr_accessor :date_for_time_type
16 |
17 | # Default parsing of ambiguous date formats. Options:
18 | # - :us (default, 01/02/2000 = 2nd of January 2000)
19 | # - :euro (01/02/2000 = 1st of February 2000)
20 | #
21 | attr_accessor :ambiguous_date_format
22 |
23 | # Set the threshold value for a two digit year to be considered last century
24 | #
25 | # Default: 30
26 | #
27 | # Example:
28 | # year = '29' is considered 2029
29 | # year = '30' is considered 1930
30 | #
31 | attr_accessor :ambiguous_year_threshold
32 |
33 | def initialize
34 | @default_timezone = :local
35 | @date_for_time_type = lambda { Time.now }
36 | @ambiguous_date_format = :us
37 | @ambiguous_year_threshold = 30
38 | end
39 | end
40 | end
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on: [push, pull_request]
4 |
5 | jobs:
6 | test:
7 |
8 | runs-on: ubuntu-latest
9 |
10 | strategy:
11 | matrix:
12 | channel: ['stable']
13 | include:
14 | - gemfile: activesupport_5_2
15 | ruby-version: 2.7
16 | - gemfile: activesupport_6_1
17 | ruby-version: 3.1
18 | - gemfile: activesupport_7_0
19 | ruby-version: 3.0
20 | - gemfile: activesupport_7_0
21 | ruby-version: 3.1
22 | - gemfile: activesupport_7_1
23 | ruby-version: 3.1
24 | - gemfile: activesupport_7_1
25 | ruby-version: 3.2
26 | - gemfile: activesupport_7_2
27 | ruby-version: 3.1
28 | - gemfile: activesupport_7_2
29 | ruby-version: 3.2
30 | - gemfile: activesupport_8_0
31 | ruby-version: 3.2
32 | - gemfile: activesupport_8_0
33 | ruby-version: 3.3
34 | - gemfile: activesupport_8_0
35 | ruby-version: 3.4
36 | steps:
37 | - uses: actions/checkout@v4
38 | - name: Set up Ruby ${{ matrix.ruby-version }}
39 | uses: ruby/setup-ruby@v1
40 | with:
41 | bundler-cache: true # 'bundle install' and cache gems
42 | ruby-version: ${{ matrix.ruby-version }}
43 | - name: Run tests
44 | env:
45 | RUBYOPT: ${{ matrix.ruby == 'ruby-head' && '--enable=frozen-string-literal' || '' }}
46 | run: bundle exec rspec
47 |
--------------------------------------------------------------------------------
/timeliness.gemspec:
--------------------------------------------------------------------------------
1 | # -*- encoding: utf-8 -*-
2 | $:.push File.expand_path("../lib", __FILE__)
3 | require "timeliness/version"
4 |
5 | Gem::Specification.new do |s|
6 |
7 | github_url = 'https://github.com/adzap/timeliness'
8 |
9 | s.name = "timeliness"
10 | s.version = Timeliness::VERSION
11 | s.platform = Gem::Platform::RUBY
12 | s.authors = ["Adam Meehan"]
13 | s.email = %q{adam.meehan@gmail.com}
14 | s.homepage = %q{http://github.com/adzap/timeliness}
15 | s.summary = %q{Date/time parsing for the control freak.}
16 | s.description = %q{Fast date/time parser with customisable formats, timezone and I18n support.}
17 | s.license = "MIT"
18 |
19 | s.metadata = {
20 | "rubygems_mfa_required" => "true",
21 | "bug_tracker_uri" => "#{github_url}/issues",
22 | "changelog_uri" => "#{github_url}/blob/master/CHANGELOG.rdoc",
23 | "source_code_uri" => "#{github_url}",
24 | "wiki_uri" => "#{github_url}/wiki",
25 | }
26 |
27 | s.add_development_dependency 'activesupport', '>= 3.2'
28 | s.add_development_dependency 'tzinfo', '>= 0.3.31'
29 | s.add_development_dependency 'rspec', '~> 3.4'
30 | s.add_development_dependency 'timecop'
31 | s.add_development_dependency 'base64'
32 | s.add_development_dependency 'bigdecimal'
33 | s.add_development_dependency 'mutex_m'
34 | s.add_development_dependency 'i18n'
35 |
36 | s.files = `git ls-files`.split("\n")
37 | s.files = `git ls-files`.split("\n") - %w{ .gitignore .rspec Gemfile Gemfile.lock }
38 | s.extra_rdoc_files = ["README.rdoc", "CHANGELOG.rdoc"]
39 | s.require_paths = ["lib"]
40 | end
41 |
--------------------------------------------------------------------------------
/lib/timeliness/helpers.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Timeliness
4 | module Helpers
5 | # Helper methods used in format component processing. See Definitions.
6 |
7 | def full_hour(hour, meridian)
8 | hour = hour.to_i
9 | return hour if meridian.nil?
10 |
11 | meridian.delete!('.')
12 | meridian.downcase!
13 |
14 | if meridian == 'am'
15 | raise(ArgumentError) if hour == 0 || hour > 12
16 | hour == 12 ? 0 : hour
17 | else
18 | hour == 12 ? hour : hour + 12
19 | end
20 | end
21 |
22 | def unambiguous_year(year)
23 | if year.length <= 2
24 | century = Time.now.year.to_s[0..1].to_i
25 | century -= 1 if year.to_i >= Timeliness.configuration.ambiguous_year_threshold
26 | year = "#{century}#{year.rjust(2,'0')}"
27 | end
28 | year.to_i
29 | end
30 |
31 | def month_index(month)
32 | return month.to_i if month.match?(/\d/)
33 | (month.length > 3 ? month_names : abbr_month_names).index { |str| month.casecmp?(str) }
34 | end
35 |
36 | def month_names
37 | i18n_loaded? ? I18n.t('date.month_names') : Date::MONTHNAMES
38 | end
39 |
40 | def abbr_month_names
41 | i18n_loaded? ? I18n.t('date.abbr_month_names') : Date::ABBR_MONTHNAMES
42 | end
43 |
44 | def microseconds(usec)
45 | (".#{usec}".to_f * 1_000_000).to_i
46 | end
47 |
48 | def offset_in_seconds(offset)
49 | offset =~ /^([-+])?(\d{2}):?(\d{2})/
50 | ($1 == '-' ? -1 : 1) * ($2.to_f * 3600 + $3.to_f * 60)
51 | end
52 |
53 | def i18n_loaded?
54 | defined?(I18n)
55 | end
56 |
57 | end
58 | end
59 |
--------------------------------------------------------------------------------
/lib/timeliness/format_set.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Timeliness
4 | class FormatSet
5 | attr_reader :formats, :regexp
6 |
7 | def self.compile(formats)
8 | new(formats).compile!
9 | end
10 |
11 | def initialize(formats)
12 | @formats = formats
13 | @formats_hash = {}
14 | @match_indexes = {}
15 | end
16 |
17 | # Compiles the formats into one big regexp. Stores the index of where
18 | # each format's capture values begin in the matchdata.
19 | def compile!
20 | regexp_string = +''
21 | @formats.inject(0) { |index, format_string|
22 | format = Format.new(format_string).compile!
23 | @formats_hash[format_string] = format
24 | @match_indexes[index] = format
25 | regexp_string.concat("(?>#{format.regexp_string})|")
26 | index + format.token_count
27 | }
28 | @regexp = %r[\A(?:#{regexp_string.chop})\z]
29 | self
30 | end
31 |
32 | def match(string, format_string=nil)
33 | format = single_format(format_string) if format_string
34 | match_regexp = format ? format.regexp : @regexp
35 |
36 | if (match_data = match_regexp.match(string))
37 | captures = match_data.captures # For a multi-format regexp there are lots of nils
38 | index = captures.index { |e| !e.nil? } # Find the start of captures for matched format
39 | values = captures[index, 8]
40 | format ||= @match_indexes[index]
41 | format.process(*values)
42 | end
43 | end
44 |
45 | def single_format(format_string)
46 | @formats_hash.fetch(format_string) { Format.new(format_string).compile! }
47 | end
48 | end
49 | end
50 |
--------------------------------------------------------------------------------
/gemfiles/activesupport_7_0.gemfile.lock:
--------------------------------------------------------------------------------
1 | PATH
2 | remote: ..
3 | specs:
4 | timeliness (0.5.0)
5 |
6 | GEM
7 | remote: http://rubygems.org/
8 | specs:
9 | activesupport (7.0.8.7)
10 | concurrent-ruby (~> 1.0, >= 1.0.2)
11 | i18n (>= 1.6, < 2)
12 | minitest (>= 5.1)
13 | tzinfo (~> 2.0)
14 | appraisal (2.5.0)
15 | bundler
16 | rake
17 | thor (>= 0.14.0)
18 | base64 (0.2.0)
19 | bigdecimal (3.1.9)
20 | concurrent-ruby (1.3.4)
21 | date (3.4.1)
22 | debug (1.10.0)
23 | irb (~> 1.10)
24 | reline (>= 0.3.8)
25 | diff-lcs (1.5.1)
26 | i18n (1.14.6)
27 | concurrent-ruby (~> 1.0)
28 | io-console (0.8.0)
29 | irb (1.14.3)
30 | rdoc (>= 4.0.0)
31 | reline (>= 0.4.2)
32 | memory_profiler (1.1.0)
33 | minitest (5.25.4)
34 | mutex_m (0.3.0)
35 | psych (5.2.2)
36 | date
37 | stringio
38 | rake (13.2.1)
39 | rdoc (6.10.0)
40 | psych (>= 4.0.0)
41 | reline (0.6.0)
42 | io-console (~> 0.5)
43 | rspec (3.13.0)
44 | rspec-core (~> 3.13.0)
45 | rspec-expectations (~> 3.13.0)
46 | rspec-mocks (~> 3.13.0)
47 | rspec-core (3.13.2)
48 | rspec-support (~> 3.13.0)
49 | rspec-expectations (3.13.3)
50 | diff-lcs (>= 1.2.0, < 2.0)
51 | rspec-support (~> 3.13.0)
52 | rspec-mocks (3.13.2)
53 | diff-lcs (>= 1.2.0, < 2.0)
54 | rspec-support (~> 3.13.0)
55 | rspec-support (3.13.2)
56 | stringio (3.1.2)
57 | thor (1.3.2)
58 | timecop (0.9.10)
59 | tzinfo (2.0.6)
60 | concurrent-ruby (~> 1.0)
61 |
62 | PLATFORMS
63 | x86_64-darwin-23
64 |
65 | DEPENDENCIES
66 | activesupport (~> 7.0.0)
67 | appraisal
68 | base64
69 | bigdecimal
70 | debug
71 | i18n
72 | memory_profiler
73 | mutex_m
74 | rspec (~> 3.4)
75 | timecop
76 | timeliness!
77 | tzinfo (>= 0.3.31)
78 |
79 | BUNDLED WITH
80 | 2.4.22
81 |
--------------------------------------------------------------------------------
/gemfiles/activesupport_5_2.gemfile.lock:
--------------------------------------------------------------------------------
1 | PATH
2 | remote: ..
3 | specs:
4 | timeliness (0.5.0)
5 |
6 | GEM
7 | remote: http://rubygems.org/
8 | specs:
9 | activesupport (5.2.8.1)
10 | concurrent-ruby (~> 1.0, >= 1.0.2)
11 | i18n (>= 0.7, < 2)
12 | minitest (~> 5.1)
13 | tzinfo (~> 1.1)
14 | appraisal (2.5.0)
15 | bundler
16 | rake
17 | thor (>= 0.14.0)
18 | base64 (0.2.0)
19 | bigdecimal (3.1.9)
20 | concurrent-ruby (1.3.4)
21 | date (3.4.1)
22 | debug (1.10.0)
23 | irb (~> 1.10)
24 | reline (>= 0.3.8)
25 | diff-lcs (1.5.1)
26 | i18n (1.14.6)
27 | concurrent-ruby (~> 1.0)
28 | io-console (0.8.0)
29 | irb (1.14.3)
30 | rdoc (>= 4.0.0)
31 | reline (>= 0.4.2)
32 | memory_profiler (1.1.0)
33 | minitest (5.25.4)
34 | mutex_m (0.3.0)
35 | psych (5.2.2)
36 | date
37 | stringio
38 | rake (13.2.1)
39 | rdoc (6.10.0)
40 | psych (>= 4.0.0)
41 | reline (0.6.0)
42 | io-console (~> 0.5)
43 | rspec (3.13.0)
44 | rspec-core (~> 3.13.0)
45 | rspec-expectations (~> 3.13.0)
46 | rspec-mocks (~> 3.13.0)
47 | rspec-core (3.13.2)
48 | rspec-support (~> 3.13.0)
49 | rspec-expectations (3.13.3)
50 | diff-lcs (>= 1.2.0, < 2.0)
51 | rspec-support (~> 3.13.0)
52 | rspec-mocks (3.13.2)
53 | diff-lcs (>= 1.2.0, < 2.0)
54 | rspec-support (~> 3.13.0)
55 | rspec-support (3.13.2)
56 | stringio (3.1.2)
57 | thor (1.3.2)
58 | thread_safe (0.3.6)
59 | timecop (0.9.10)
60 | tzinfo (1.2.11)
61 | thread_safe (~> 0.1)
62 |
63 | PLATFORMS
64 | x86_64-darwin-23
65 |
66 | DEPENDENCIES
67 | activesupport (~> 5.2.0)
68 | appraisal
69 | base64
70 | bigdecimal
71 | debug
72 | i18n
73 | memory_profiler
74 | mutex_m
75 | rspec (~> 3.4)
76 | timecop
77 | timeliness!
78 | tzinfo (>= 0.3.31)
79 |
80 | BUNDLED WITH
81 | 2.4.22
82 |
--------------------------------------------------------------------------------
/gemfiles/activesupport_6_1.gemfile.lock:
--------------------------------------------------------------------------------
1 | PATH
2 | remote: ..
3 | specs:
4 | timeliness (0.5.0)
5 |
6 | GEM
7 | remote: http://rubygems.org/
8 | specs:
9 | activesupport (6.1.7.10)
10 | concurrent-ruby (~> 1.0, >= 1.0.2)
11 | i18n (>= 1.6, < 2)
12 | minitest (>= 5.1)
13 | tzinfo (~> 2.0)
14 | zeitwerk (~> 2.3)
15 | appraisal (2.5.0)
16 | bundler
17 | rake
18 | thor (>= 0.14.0)
19 | base64 (0.2.0)
20 | bigdecimal (3.1.9)
21 | concurrent-ruby (1.3.4)
22 | date (3.4.1)
23 | debug (1.10.0)
24 | irb (~> 1.10)
25 | reline (>= 0.3.8)
26 | diff-lcs (1.5.1)
27 | i18n (1.14.6)
28 | concurrent-ruby (~> 1.0)
29 | io-console (0.8.0)
30 | irb (1.14.3)
31 | rdoc (>= 4.0.0)
32 | reline (>= 0.4.2)
33 | memory_profiler (1.1.0)
34 | minitest (5.25.4)
35 | mutex_m (0.3.0)
36 | psych (5.2.2)
37 | date
38 | stringio
39 | rake (13.2.1)
40 | rdoc (6.10.0)
41 | psych (>= 4.0.0)
42 | reline (0.6.0)
43 | io-console (~> 0.5)
44 | rspec (3.13.0)
45 | rspec-core (~> 3.13.0)
46 | rspec-expectations (~> 3.13.0)
47 | rspec-mocks (~> 3.13.0)
48 | rspec-core (3.13.2)
49 | rspec-support (~> 3.13.0)
50 | rspec-expectations (3.13.3)
51 | diff-lcs (>= 1.2.0, < 2.0)
52 | rspec-support (~> 3.13.0)
53 | rspec-mocks (3.13.2)
54 | diff-lcs (>= 1.2.0, < 2.0)
55 | rspec-support (~> 3.13.0)
56 | rspec-support (3.13.2)
57 | stringio (3.1.2)
58 | thor (1.3.2)
59 | timecop (0.9.10)
60 | tzinfo (2.0.6)
61 | concurrent-ruby (~> 1.0)
62 | zeitwerk (2.6.18)
63 |
64 | PLATFORMS
65 | x86_64-darwin-23
66 |
67 | DEPENDENCIES
68 | activesupport (~> 6.1.0)
69 | appraisal
70 | base64
71 | bigdecimal
72 | debug
73 | i18n
74 | memory_profiler
75 | mutex_m
76 | rspec (~> 3.4)
77 | timecop
78 | timeliness!
79 | tzinfo (>= 0.3.31)
80 |
81 | BUNDLED WITH
82 | 2.4.22
83 |
--------------------------------------------------------------------------------
/gemfiles/activesupport_6_0.gemfile.lock:
--------------------------------------------------------------------------------
1 | PATH
2 | remote: ..
3 | specs:
4 | timeliness (0.5.0)
5 |
6 | GEM
7 | remote: http://rubygems.org/
8 | specs:
9 | activesupport (6.0.6.1)
10 | concurrent-ruby (~> 1.0, >= 1.0.2)
11 | i18n (>= 0.7, < 2)
12 | minitest (~> 5.1)
13 | tzinfo (~> 1.1)
14 | zeitwerk (~> 2.2, >= 2.2.2)
15 | appraisal (2.5.0)
16 | bundler
17 | rake
18 | thor (>= 0.14.0)
19 | base64 (0.2.0)
20 | bigdecimal (3.1.9)
21 | concurrent-ruby (1.3.4)
22 | date (3.4.1)
23 | debug (1.10.0)
24 | irb (~> 1.10)
25 | reline (>= 0.3.8)
26 | diff-lcs (1.5.1)
27 | i18n (1.14.6)
28 | concurrent-ruby (~> 1.0)
29 | io-console (0.8.0)
30 | irb (1.14.3)
31 | rdoc (>= 4.0.0)
32 | reline (>= 0.4.2)
33 | memory_profiler (1.1.0)
34 | minitest (5.25.4)
35 | mutex_m (0.3.0)
36 | psych (5.2.2)
37 | date
38 | stringio
39 | rake (13.2.1)
40 | rdoc (6.10.0)
41 | psych (>= 4.0.0)
42 | reline (0.6.0)
43 | io-console (~> 0.5)
44 | rspec (3.13.0)
45 | rspec-core (~> 3.13.0)
46 | rspec-expectations (~> 3.13.0)
47 | rspec-mocks (~> 3.13.0)
48 | rspec-core (3.13.2)
49 | rspec-support (~> 3.13.0)
50 | rspec-expectations (3.13.3)
51 | diff-lcs (>= 1.2.0, < 2.0)
52 | rspec-support (~> 3.13.0)
53 | rspec-mocks (3.13.2)
54 | diff-lcs (>= 1.2.0, < 2.0)
55 | rspec-support (~> 3.13.0)
56 | rspec-support (3.13.2)
57 | stringio (3.1.2)
58 | thor (1.3.2)
59 | thread_safe (0.3.6)
60 | timecop (0.9.10)
61 | tzinfo (1.2.11)
62 | thread_safe (~> 0.1)
63 | zeitwerk (2.6.18)
64 |
65 | PLATFORMS
66 | x86_64-darwin-23
67 |
68 | DEPENDENCIES
69 | activesupport (~> 6.0.0)
70 | appraisal
71 | base64
72 | bigdecimal
73 | debug
74 | i18n
75 | memory_profiler
76 | mutex_m
77 | rspec (~> 3.4)
78 | timecop
79 | timeliness!
80 | tzinfo (>= 0.3.31)
81 |
82 | BUNDLED WITH
83 | 2.4.22
84 |
--------------------------------------------------------------------------------
/gemfiles/activesupport_7_2.gemfile.lock:
--------------------------------------------------------------------------------
1 | PATH
2 | remote: ..
3 | specs:
4 | timeliness (0.5.0)
5 |
6 | GEM
7 | remote: http://rubygems.org/
8 | specs:
9 | activesupport (7.2.2.1)
10 | base64
11 | benchmark (>= 0.3)
12 | bigdecimal
13 | concurrent-ruby (~> 1.0, >= 1.3.1)
14 | connection_pool (>= 2.2.5)
15 | drb
16 | i18n (>= 1.6, < 2)
17 | logger (>= 1.4.2)
18 | minitest (>= 5.1)
19 | securerandom (>= 0.3)
20 | tzinfo (~> 2.0, >= 2.0.5)
21 | appraisal (2.5.0)
22 | bundler
23 | rake
24 | thor (>= 0.14.0)
25 | base64 (0.2.0)
26 | benchmark (0.4.0)
27 | bigdecimal (3.1.9)
28 | concurrent-ruby (1.3.4)
29 | connection_pool (2.4.1)
30 | date (3.4.1)
31 | debug (1.10.0)
32 | irb (~> 1.10)
33 | reline (>= 0.3.8)
34 | diff-lcs (1.5.1)
35 | drb (2.2.1)
36 | i18n (1.14.6)
37 | concurrent-ruby (~> 1.0)
38 | io-console (0.8.0)
39 | irb (1.14.3)
40 | rdoc (>= 4.0.0)
41 | reline (>= 0.4.2)
42 | logger (1.6.4)
43 | memory_profiler (1.1.0)
44 | minitest (5.25.4)
45 | mutex_m (0.3.0)
46 | psych (5.2.2)
47 | date
48 | stringio
49 | rake (13.2.1)
50 | rdoc (6.10.0)
51 | psych (>= 4.0.0)
52 | reline (0.6.0)
53 | io-console (~> 0.5)
54 | rspec (3.13.0)
55 | rspec-core (~> 3.13.0)
56 | rspec-expectations (~> 3.13.0)
57 | rspec-mocks (~> 3.13.0)
58 | rspec-core (3.13.2)
59 | rspec-support (~> 3.13.0)
60 | rspec-expectations (3.13.3)
61 | diff-lcs (>= 1.2.0, < 2.0)
62 | rspec-support (~> 3.13.0)
63 | rspec-mocks (3.13.2)
64 | diff-lcs (>= 1.2.0, < 2.0)
65 | rspec-support (~> 3.13.0)
66 | rspec-support (3.13.2)
67 | securerandom (0.4.1)
68 | stringio (3.1.2)
69 | thor (1.3.2)
70 | timecop (0.9.10)
71 | tzinfo (2.0.6)
72 | concurrent-ruby (~> 1.0)
73 |
74 | PLATFORMS
75 | x86_64-darwin-23
76 |
77 | DEPENDENCIES
78 | activesupport (~> 7.2.0)
79 | appraisal
80 | base64
81 | bigdecimal
82 | debug
83 | i18n
84 | memory_profiler
85 | mutex_m
86 | rspec (~> 3.4)
87 | timecop
88 | timeliness!
89 | tzinfo (>= 0.3.31)
90 |
91 | BUNDLED WITH
92 | 2.4.22
93 |
--------------------------------------------------------------------------------
/gemfiles/activesupport_8_0.gemfile.lock:
--------------------------------------------------------------------------------
1 | PATH
2 | remote: ..
3 | specs:
4 | timeliness (0.5.0)
5 |
6 | GEM
7 | remote: http://rubygems.org/
8 | specs:
9 | activesupport (8.0.1)
10 | base64
11 | benchmark (>= 0.3)
12 | bigdecimal
13 | concurrent-ruby (~> 1.0, >= 1.3.1)
14 | connection_pool (>= 2.2.5)
15 | drb
16 | i18n (>= 1.6, < 2)
17 | logger (>= 1.4.2)
18 | minitest (>= 5.1)
19 | securerandom (>= 0.3)
20 | tzinfo (~> 2.0, >= 2.0.5)
21 | uri (>= 0.13.1)
22 | appraisal (2.5.0)
23 | bundler
24 | rake
25 | thor (>= 0.14.0)
26 | base64 (0.2.0)
27 | benchmark (0.4.0)
28 | bigdecimal (3.1.9)
29 | concurrent-ruby (1.3.4)
30 | connection_pool (2.4.1)
31 | date (3.4.1)
32 | debug (1.10.0)
33 | irb (~> 1.10)
34 | reline (>= 0.3.8)
35 | diff-lcs (1.5.1)
36 | drb (2.2.1)
37 | i18n (1.14.6)
38 | concurrent-ruby (~> 1.0)
39 | io-console (0.8.0)
40 | irb (1.14.3)
41 | rdoc (>= 4.0.0)
42 | reline (>= 0.4.2)
43 | logger (1.6.4)
44 | memory_profiler (1.1.0)
45 | minitest (5.25.4)
46 | mutex_m (0.3.0)
47 | psych (5.2.2)
48 | date
49 | stringio
50 | rake (13.2.1)
51 | rdoc (6.10.0)
52 | psych (>= 4.0.0)
53 | reline (0.6.0)
54 | io-console (~> 0.5)
55 | rspec (3.13.0)
56 | rspec-core (~> 3.13.0)
57 | rspec-expectations (~> 3.13.0)
58 | rspec-mocks (~> 3.13.0)
59 | rspec-core (3.13.2)
60 | rspec-support (~> 3.13.0)
61 | rspec-expectations (3.13.3)
62 | diff-lcs (>= 1.2.0, < 2.0)
63 | rspec-support (~> 3.13.0)
64 | rspec-mocks (3.13.2)
65 | diff-lcs (>= 1.2.0, < 2.0)
66 | rspec-support (~> 3.13.0)
67 | rspec-support (3.13.2)
68 | securerandom (0.4.1)
69 | stringio (3.1.2)
70 | thor (1.3.2)
71 | timecop (0.9.10)
72 | tzinfo (2.0.6)
73 | concurrent-ruby (~> 1.0)
74 | uri (1.0.2)
75 |
76 | PLATFORMS
77 | ruby
78 | x86_64-darwin-23
79 |
80 | DEPENDENCIES
81 | activesupport (~> 8.0.0)
82 | appraisal
83 | base64
84 | bigdecimal
85 | debug
86 | i18n
87 | memory_profiler
88 | mutex_m
89 | rspec (~> 3.4)
90 | timecop
91 | timeliness!
92 | tzinfo (>= 0.3.31)
93 |
94 | BUNDLED WITH
95 | 2.6.2
96 |
--------------------------------------------------------------------------------
/lib/timeliness/format.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Timeliness
4 | class Format
5 | include Helpers
6 |
7 | CompilationFailed = Class.new(StandardError)
8 |
9 | attr_reader :format_string, :regexp, :regexp_string, :token_count
10 |
11 | def initialize(format_string)
12 | @format_string = format_string
13 | end
14 |
15 | def compile!
16 | found_tokens, token_order = [], []
17 |
18 | format = format_string.dup
19 | format.gsub!(/([\.\\])/, '\\\\\1') # escapes dots and backslashes
20 |
21 | # Substitute tokens with numbered placeholder
22 | Definitions.sorted_token_keys.each do |token|
23 | format.gsub!(token) do
24 | token_regexp_str, arg_key = Definitions.format_tokens[token]
25 |
26 | if arg_key && found_tokens.rassoc(arg_key)
27 | raise CompilationFailed, "Token '#{token}' was found more than once in format '#{format_string}'. This has unexpected effects should be removed." if count > 0
28 | end
29 |
30 | found_tokens << [ token_regexp_str, arg_key ]
31 |
32 | token_index = found_tokens.size - 1
33 | "%<#{token_index}>"
34 | end
35 | end
36 |
37 | # Replace placeholders with token regexps
38 | format.gsub!(/%<(\d+)>/) do
39 | token_regexp_str, arg_key = found_tokens[$1.to_i]
40 |
41 | if arg_key
42 | token_order << arg_key
43 | "(#{token_regexp_str})"
44 | else
45 | token_regexp_str
46 | end
47 | end
48 |
49 | @token_count = token_order.size
50 |
51 | define_process_method(token_order)
52 | @regexp_string = format
53 | @regexp = Regexp.new("^(?>#{format})$")
54 | self
55 | rescue => ex
56 | raise CompilationFailed, "The format '#{format_string}' failed to compile using regexp string #{format}. Error message: #{ex.inspect}"
57 | end
58 |
59 | # Redefined on compile
60 | def process(*args); end
61 |
62 | private
63 |
64 | def define_process_method(components)
65 | values = [nil] * 8
66 | components.each do |component|
67 | position, code = Definitions.format_components[component]
68 | values[position] = code || "#{component}.to_i" if position
69 | end
70 | components << '*_' # absorb any excess arguments not used by format
71 | instance_eval <<-DEF
72 | def process(#{components.join(',')})
73 | [#{values.map { |i| i || 'nil' }.join(',')}]
74 | end
75 | DEF
76 | end
77 | end
78 | end
79 |
--------------------------------------------------------------------------------
/spec/timeliness/core_ext/string_spec.rb:
--------------------------------------------------------------------------------
1 | describe Timeliness::CoreExt, 'String' do
2 | # Test values taken from ActiveSupport unit tests for compatibility
3 |
4 | describe "#to_time" do
5 | it 'should convert valid string to Time object in default zone' do
6 | expect("2005-02-27 23:50".to_time).to eq Time.utc(2005, 2, 27, 23, 50)
7 | end
8 |
9 | it 'should convert ISO 8601 string to Time object' do
10 | expect("2005-02-27T23:50:19.275038".to_time).to eq Time.utc(2005, 2, 27, 23, 50, 19, 275038)
11 | end
12 |
13 | context "with :local" do
14 | it 'should convert valid string to local time' do
15 | expect("2005-02-27 23:50".to_time(:local)).to eq Time.local(2005, 2, 27, 23, 50)
16 | end
17 |
18 | it 'should convert ISO 8601 string to local time' do
19 | expect("2005-02-27T23:50:19.275038".to_time(:local)).to eq Time.local(2005, 2, 27, 23, 50, 19, 275038)
20 | end
21 | end
22 |
23 | it 'should convert valid future string to Time object' do
24 | expect("2039-02-27 23:50".to_time(:local)).to eq Time.local(2039, 2, 27, 23, 50)
25 | end
26 |
27 | it 'should convert valid future string to Time object' do
28 | expect("2039-02-27 23:50".to_time).to eq DateTime.civil(2039, 2, 27, 23, 50)
29 | end
30 |
31 | it 'should convert empty string to nil' do
32 | expect(''.to_time).to be_nil
33 | end
34 | end
35 |
36 | describe "#to_datetime" do
37 | it 'should convert valid string to DateTime object' do
38 | expect("2039-02-27 23:50".to_datetime).to eq DateTime.civil(2039, 2, 27, 23, 50)
39 | end
40 |
41 | it 'should convert to DateTime object with UTC offset' do
42 | expect("2039-02-27 23:50".to_datetime.offset).to eq 0
43 | end
44 |
45 | it 'should convert ISO 8601 string to DateTime object' do
46 | datetime = DateTime.civil(2039, 2, 27, 23, 50, 19 + Rational(275038, 1000000), "-04:00")
47 | expect("2039-02-27T23:50:19.275038-04:00".to_datetime).to eq datetime
48 | end
49 |
50 | it 'should use Rubys default start value' do
51 | # Taken from ActiveSupport unit tests. Not sure on the implication.
52 | expect("2039-02-27 23:50".to_datetime.start).to eq ::Date::ITALY
53 | end
54 |
55 | it 'should convert empty string to nil' do
56 | expect(''.to_datetime).to be_nil
57 | end
58 | end
59 |
60 | describe "#to_date" do
61 | it 'should convert string to Date object' do
62 | expect("2005-02-27".to_date).to eq Date.new(2005, 2, 27)
63 | end
64 |
65 | it 'should convert empty string to nil' do
66 | expect(''.to_date).to be_nil
67 | end
68 | end
69 |
70 | end
71 |
--------------------------------------------------------------------------------
/CHANGELOG.rdoc:
--------------------------------------------------------------------------------
1 | = 0.5.3 - 2025-05-13
2 | * Regression bug in offset_in_seconds helper incorrectly calculating seconds from minutes portion
3 |
4 | = 0.5.2 - 2025-01-31
5 | * Reduce allocations through on the parse hot path
6 |
7 | = 0.5.1 - 2025-01-07
8 | * Make frozen string compatible
9 |
10 | = 0.5.0 - 2024-12-02
11 | * Reduce allocations through some internal parsing changes
12 | * Changed parse method arg handling to simple using keyword args
13 |
14 | = 0.4.5 - 2023-01-19
15 | * Support case insensitive months
16 | * Migrated to Github Actions (@petergoldstein)
17 | * Various doc, spec, and gemspec fixes and updates (@tagliala)
18 |
19 | = 0.4.4 - 2019-08-06
20 | * Raise compilation error if token with capturing arg is used more than once in a format
21 | * Some small internal refactorings in format compilation
22 |
23 | = 0.4.3 - 2019-06-16
24 | * Fixed `Timeliness.ambiguous_date_format` being used in new threads if custom value set
25 | * Moved all config from Timeliness to new Configuration class. Delegated all old config methods to Timeliness.configuration instance.
26 |
27 | = 0.4.2 - 2019-06-15
28 | * Fixed thread safe issue that forced you to use one of the date format methods e.g. `use_euro_formats` to initialize the format sets in each new thread. Now a new thread will default to the global default (main thread).
29 | * Add `Timeliness.ambiguous_date_format` config setting (:us or :euro) to control global default for date format sets.
30 |
31 | = 0.4.1 - 2019-06-11
32 | * Add format for ISO 8601 with usec and 'Z' UTC zone offset (jartek)
33 | * Fix ISO 8601 parsing bug where Z was not recognised as UTC
34 | * Add 'zt' format token to support 'Z' (Zulu time) zone offset i.e. +00:00 or UTC
35 |
36 | = 0.4.0 - 2019-02-09
37 | * Add threadsafety for use_euro_formats & use_us_formats to allow runtime switching (andruby, timdiggins)
38 |
39 | = 0.3.10 - 2019-02-06
40 | * Fixed file permissions in gem build
41 |
42 | = 0.3.9 - 2019-02-03 [YANKED]
43 | * Fix for parsing invalid datetime string with valid timezone raising exception (lni_T)
44 | * Add license name in gemspec (Robert Reiz)
45 | * Fix typo in README format example
46 |
47 | = 0.3.8 - 2016-01-06
48 | * Add formats for standard Ruby string representations of Time
49 | * Updated specs to RSpec v3
50 | * Added some gem specific exception classes
51 |
52 | = 0.3.7 - 2012-10-03
53 | * Change to a hot switch between US and Euro formats without a compile.
54 | * Fix date parsing with bad month name defaulting to 1 if year and day present.
55 | * Fix date parsing with nil month.
56 |
57 | = 0.3.6 - 2012-03-29
58 | * Fix bug with month_index using Integer method and leading zeroes treated as octal.
59 |
60 | = 0.3.5 - 2012-03-29
61 | * Correctly handle month value of 0. Fixes issue#4.
62 |
63 | = 0.3.4 - 2011-05-26
64 | * Compact time array when creating time in zone so that invalid time handling works properly. Fixes issue#3.
65 |
66 | = 0.3.3 - 2011-01-02
67 | * Add String core extension for to_time, to_date and to_datetime methods, like ActiveSupport
68 | * Allow arbitrary format string as :format option and it will be compiled, if not found.
69 |
70 | = 0.3.2 - 2010-11-26
71 | * Catch all errors for ActiveSupport not being loaded for more helpful error
72 |
73 | = 0.3.1 - 2010-11-27
74 | * Fix issue with 2nd argument options being overidden
75 |
76 | = 0.3.0 - 2010-11-27
77 | * Support for parsed timezone offset or abbreviation being used in creating time value
78 | * Added timezone abbreviation mapping config option
79 | * Allow 2nd argument for parse method to be the type, :now value, or options hash.
80 | * Refactoring
81 |
82 | = 0.2.0 - 2010-10-27
83 | * Allow a lambda for date_for_time_type which is evaluated on parse
84 | * Return the offset or zone in array from _parse
85 | * Give a nicer error message if use a zone and ActiveSupport is not loaded.
86 | * Removed some aliases used in validates_timeliness and are no longer needed.
87 | * Some minor spec fixes
88 |
89 | = 0.1.1 - 2010-10-14
90 | * Alias for validates_timeliness compatibility
91 | * Tiny cleanup
92 |
93 | = 0.1.0 - 2010-10-14
94 | * Initial release
95 |
--------------------------------------------------------------------------------
/benchmark.rb:
--------------------------------------------------------------------------------
1 | $:.unshift(File.expand_path('lib'))
2 |
3 | require 'benchmark'
4 | require 'time'
5 | require 'timeliness'
6 |
7 | if defined?(JRUBY_VERSION)
8 | # Warm up JRuby
9 | 20_000.times do
10 | Time.parse("2000-01-04 12:12:12")
11 | Timeliness::Parser.parse("2000-01-04 12:12:12", :datetime)
12 | end
13 | end
14 |
15 | n = 10_000
16 | Benchmark.bm(40) do |x|
17 | x.report('timeliness - datetime') {
18 | n.times do
19 | Timeliness::Parser.parse("2000-01-04 12:12:12", :datetime)
20 | end
21 | }
22 |
23 | x.report('timeliness - datetime with :format') {
24 | n.times do
25 | Timeliness::Parser.parse("2000-01-04 12:12:12", :datetime, format: 'yyyy-mm-dd hh:nn:ss')
26 | end
27 | }
28 |
29 | x.report('timeliness - date') {
30 | n.times do
31 | Timeliness::Parser.parse("2000-01-04", :date)
32 | end
33 | }
34 |
35 | x.report('timeliness - date as datetime') {
36 | n.times do
37 | Timeliness::Parser.parse("2000-01-04", :datetime)
38 | end
39 | }
40 |
41 | x.report('timeliness - time') {
42 | n.times do
43 | Timeliness::Parser.parse("12:01:02", :time)
44 | end
45 | }
46 |
47 | x.report('timeliness - no type with datetime value') {
48 | n.times do
49 | Timeliness::Parser.parse("2000-01-04 12:12:12")
50 | end
51 | }
52 |
53 | x.report('timeliness - no type with date value') {
54 | n.times do
55 | Timeliness::Parser.parse("2000-01-04")
56 | end
57 | }
58 |
59 | x.report('timeliness - no type with time value') {
60 | n.times do
61 | Timeliness::Parser.parse("12:01:02")
62 | end
63 | }
64 |
65 | x.report('timeliness - invalid format datetime') {
66 | n.times do
67 | Timeliness::Parser.parse("20xx-01-04 12:12:12", :datetime)
68 | end
69 | }
70 |
71 | x.report('timeliness - invalid format date') {
72 | n.times do
73 | Timeliness::Parser.parse("20xx-01-04", :date)
74 | end
75 | }
76 |
77 | x.report('timeliness - invalid format time') {
78 | n.times do
79 | Timeliness::Parser.parse("12:xx:02", :time)
80 | end
81 | }
82 |
83 | x.report('timeliness - invalid value datetime') {
84 | n.times do
85 | Timeliness::Parser.parse("2000-01-32 12:12:12", :datetime)
86 | end
87 | }
88 |
89 | x.report('timeliness - invalid value date') {
90 | n.times do
91 | Timeliness::Parser.parse("2000-01-32", :date)
92 | end
93 | }
94 |
95 | x.report('timeliness - invalid value time') {
96 | n.times do
97 | Timeliness::Parser.parse("12:61:02", :time)
98 | end
99 | }
100 |
101 | x.report('ISO regexp for datetime') {
102 | n.times do
103 | "2000-01-04 12:12:12" =~ /\A(\d{4})-(\d{2})-(\d{2}) (\d{2})[\. :](\d{2})([\. :](\d{2}))?\Z/
104 | Time.mktime($1.to_i, $2.to_i, $3.to_i, $3.to_i, $5.to_i, $6.to_i)
105 | end
106 | }
107 |
108 | x.report('Time.parse - valid') {
109 | n.times do
110 | Time.parse("2000-01-04 12:12:12")
111 | end
112 | }
113 |
114 | x.report('Time.parse - invalid ') {
115 | n.times do
116 | Time.parse("2000-01-32 12:12:12") rescue nil
117 | end
118 | }
119 |
120 | x.report('Date._parse - valid') {
121 | n.times do
122 | hash = Date._parse("2000-01-04 12:12:12")
123 | Time.mktime(hash[:year], hash[:mon], hash[:mday], hash[:hour], hash[:min], hash[:sec])
124 | end
125 | }
126 |
127 | x.report('Date._parse - invalid ') {
128 | n.times do
129 | hash = Date._parse("2000-01-32 12:12:12")
130 | Time.mktime(hash[:year], hash[:mon], hash[:mday], hash[:hour], hash[:min], hash[:sex]) rescue nil
131 | end
132 | }
133 |
134 | x.report('strptime - valid') {
135 | n.times do
136 | DateTime.strptime("2000-01-04 12:12:12", '%Y-%m-%d %H:%M:%s')
137 | end
138 | }
139 |
140 | x.report('strptime - invalid') {
141 | n.times do
142 | DateTime.strptime("2000-00-04 12:12:12", '%Y-%m-%d %H:%M:%s') rescue nil
143 | end
144 | }
145 | end
146 |
--------------------------------------------------------------------------------
/spec/timeliness/helpers_spec.rb:
--------------------------------------------------------------------------------
1 | describe Timeliness::Helpers do
2 | include Timeliness::Helpers
3 |
4 | describe "#full_hour" do
5 | it "should convert a 12-hour clock AM time to 24-hour format correctly" do
6 | expect(full_hour(12, 'am')).to eq 0
7 | expect(full_hour(1, 'am')).to eq 1
8 | expect(full_hour(10, 'am')).to eq 10
9 | end
10 |
11 | it "should convert a 12-hour clock PM time to 24-hour format correctly" do
12 | expect(full_hour(12, 'pm')).to eq 12
13 | expect(full_hour(1, 'pm')).to eq 13
14 | expect(full_hour(10, 'pm')).to eq 22
15 | end
16 |
17 | it "should raise ArgumentError when given an hour of 0 with AM meridian" do
18 | expect { full_hour(0, 'am') }.to raise_error(ArgumentError)
19 | end
20 |
21 | it "should raise ArgumentError when given an hour greater than 12 with AM meridian" do
22 | expect { full_hour(13, 'am') }.to raise_error(ArgumentError)
23 | end
24 |
25 | it "should handle meridian strings with periods" do
26 | expect(full_hour(10, 'A.M.')).to eq 10
27 | expect(full_hour(10, 'P.M.')).to eq 22
28 | expect(full_hour(12, 'A.M.')).to eq 0
29 | expect(full_hour(12, 'P.M.')).to eq 12
30 | end
31 | end
32 |
33 | describe "#unambiguous_year" do
34 | before do
35 | @original_threshold = Timeliness.configuration.ambiguous_year_threshold
36 | Timeliness.configuration.ambiguous_year_threshold = 30
37 | Timecop.freeze(Time.new(2023, 1, 1))
38 | end
39 |
40 | after do
41 | Timeliness.configuration.ambiguous_year_threshold = @original_threshold
42 | Timecop.return
43 | end
44 |
45 | it "should convert 2-digit years to 4-digit years based on the current century and ambiguous year threshold" do
46 | # Current century (21st) for years below threshold
47 | expect(unambiguous_year('29')).to eq 2029
48 |
49 | # Previous century (20th) for years above or equal to threshold
50 | expect(unambiguous_year('30')).to eq 1930
51 | expect(unambiguous_year('99')).to eq 1999
52 |
53 | # Should not modify years that are already 4-digits
54 | expect(unambiguous_year('2023')).to eq 2023
55 |
56 | # Should handle single digit years with padding
57 | expect(unambiguous_year('7')).to eq 2007
58 |
59 | # Should handle years at century boundaries
60 | expect(unambiguous_year('00')).to eq 2000
61 | end
62 | end
63 |
64 | describe "#month_index" do
65 | before do
66 | allow(self).to receive(:i18n_loaded?).and_return(false)
67 | end
68 |
69 | it "should correctly parse month names as month indices regardless of case" do
70 | # Testing with full month names
71 | expect(month_index("january")).to eq 1
72 | expect(month_index("MARCH")).to eq 3
73 | expect(month_index("DeCeMbEr")).to eq 12
74 |
75 | # Testing with abbreviated month names
76 | expect(month_index("jan")).to eq 1
77 | expect(month_index("MAR")).to eq 3
78 | expect(month_index("deC")).to eq 12
79 |
80 | # Testing with numeric month
81 | expect(month_index("7")).to eq 7
82 | end
83 | end
84 |
85 | describe "#microseconds" do
86 | it "should convert microsecond strings to integer microsecond values" do
87 | expect(microseconds('0')).to eq 0
88 | expect(microseconds('1')).to eq 100000
89 | expect(microseconds('01')).to eq 10000
90 | expect(microseconds('001')).to eq 1000
91 | expect(microseconds('9')).to eq 900000
92 | expect(microseconds('99')).to eq 990000
93 | expect(microseconds('999')).to eq 999000
94 | expect(microseconds('999999')).to eq 999999
95 | end
96 | end
97 |
98 | describe "#offset_in_seconds" do
99 | it "should calculate offset in seconds from timezone string formats" do
100 | # Standard format with colon
101 | expect(offset_in_seconds('+10:00')).to eq 36000
102 | expect(offset_in_seconds('-05:30')).to eq -19800
103 |
104 | # Format without colon
105 | expect(offset_in_seconds('+1030')).to eq 37800
106 | expect(offset_in_seconds('-0530')).to eq -19800
107 |
108 | # Default positive sign when omitted
109 | expect(offset_in_seconds('08:00')).to eq 28800
110 |
111 | # Zero offset
112 | expect(offset_in_seconds('+00:00')).to eq 0
113 | expect(offset_in_seconds('-00:00')).to eq 0
114 | end
115 | end
116 | end
117 |
--------------------------------------------------------------------------------
/spec/spec_helper.rb:
--------------------------------------------------------------------------------
1 | # This file was generated by the `rspec --init` command. Conventionally, all
2 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`.
3 | # The generated `.rspec` file contains `--require spec_helper` which will cause
4 | # this file to always be loaded, without a need to explicitly require it in any
5 | # files.
6 | #
7 | # Given that it is always loaded, you are encouraged to keep this file as
8 | # light-weight as possible. Requiring heavyweight dependencies from this file
9 | # will add to the boot time of your test suite on EVERY test run, even for an
10 | # individual file that may not need all of that loaded. Instead, consider making
11 | # a separate helper file that requires the additional dependencies and performs
12 | # the additional setup, and require it from the spec files that actually need
13 | # it.
14 | #
15 | # The `.rspec` file also contains a few flags that are not defaults but that
16 | # users commonly want.
17 | #
18 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration
19 | RSpec.configure do |config|
20 | # rspec-expectations config goes here. You can use an alternate
21 | # assertion/expectation library such as wrong or the stdlib/minitest
22 | # assertions if you prefer.
23 | config.expect_with :rspec do |expectations|
24 | # This option will default to `true` in RSpec 4. It makes the `description`
25 | # and `failure_message` of custom matchers include text for helper methods
26 | # defined using `chain`, e.g.:
27 | # be_bigger_than(2).and_smaller_than(4).description
28 | # # => "be bigger than 2 and smaller than 4"
29 | # ...rather than:
30 | # # => "be bigger than 2"
31 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true
32 | end
33 |
34 | # rspec-mocks config goes here. You can use an alternate test double
35 | # library (such as bogus or mocha) by changing the `mock_with` option here.
36 | config.mock_with :rspec do |mocks|
37 | # Prevents you from mocking or stubbing a method that does not exist on
38 | # a real object. This is generally recommended, and will default to
39 | # `true` in RSpec 4.
40 | mocks.verify_partial_doubles = true
41 | end
42 |
43 | # The settings below are suggested to provide a good initial experience
44 | # with RSpec, but feel free to customize to your heart's content.
45 | =begin
46 | # These two settings work together to allow you to limit a spec run
47 | # to individual examples or groups you care about by tagging them with
48 | # `:focus` metadata. When nothing is tagged with `:focus`, all examples
49 | # get run.
50 | config.filter_run :focus
51 | config.run_all_when_everything_filtered = true
52 |
53 | # Allows RSpec to persist some state between runs in order to support
54 | # the `--only-failures` and `--next-failure` CLI options. We recommend
55 | # you configure your source control system to ignore this file.
56 | config.example_status_persistence_file_path = "spec/examples.txt"
57 |
58 | # Limits the available syntax to the non-monkey patched syntax that is
59 | # recommended. For more details, see:
60 | # - http://rspec.info/blog/2012/06/rspecs-new-expectation-syntax/
61 | # - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/
62 | # - http://rspec.info/blog/2014/05/notable-changes-in-rspec-3/#zero-monkey-patching-mode
63 | config.disable_monkey_patching!
64 |
65 | # This setting enables warnings. It's recommended, but in some cases may
66 | # be too noisy due to issues in dependencies.
67 | config.warnings = true
68 |
69 | # Many RSpec users commonly either run the entire suite or an individual
70 | # file, and it's useful to allow more verbose output when running an
71 | # individual spec file.
72 | if config.files_to_run.one?
73 | # Use the documentation formatter for detailed output,
74 | # unless a formatter has already been configured
75 | # (e.g. via a command-line flag).
76 | config.default_formatter = 'doc'
77 | end
78 |
79 | # Print the 10 slowest examples and example groups at the
80 | # end of the spec run, to help surface which specs are running
81 | # particularly slow.
82 | config.profile_examples = 10
83 |
84 | # Run specs in random order to surface order dependencies. If you find an
85 | # order dependency and want to debug it, you can fix the order by providing
86 | # the seed, which is printed after each run.
87 | # --seed 1234
88 | config.order = :random
89 |
90 | # Seed global randomization in this process using the `--seed` CLI option.
91 | # Setting this allows you to use `--seed` to deterministically reproduce
92 | # test failures related to randomization by passing the same `--seed` value
93 | # as the one that triggered the failure.
94 | Kernel.srand config.seed
95 | =end
96 | end
97 |
--------------------------------------------------------------------------------
/spec/timeliness/definitions_spec.rb:
--------------------------------------------------------------------------------
1 | describe Timeliness::Definitions do
2 | context "add_formats" do
3 | before do
4 | @default_formats = definitions.time_formats.dup
5 | end
6 |
7 | it "should add format to format array" do
8 | definitions.add_formats(:time, "h o'clock")
9 | expect(definitions.time_formats).to include("h o'clock")
10 | end
11 |
12 | it "should parse new format after its added" do
13 | should_not_parse("12 o'clock", :time)
14 | definitions.add_formats(:time, "h o'clock")
15 | should_parse("12 o'clock", :time)
16 | end
17 |
18 | it "should raise error if format exists" do
19 | expect { definitions.add_formats(:time, "hh:nn:ss") }.to raise_error(Timeliness::Definitions::DuplicateFormat)
20 | end
21 |
22 | context "with :before option" do
23 | it "should add new format with higher precedence" do
24 | definitions.add_formats(:time, "ss:hh:nn", before: 'hh:nn:ss')
25 | time_array = parser._parse('59:23:58', :time)
26 | expect(time_array).to eq [nil,nil,nil,23,58,59,nil,nil]
27 | end
28 |
29 | it "should raise error if :before format does not exist" do
30 | expect { definitions.add_formats(:time, "ss:hh:nn", before: 'nn:hh:ss') }.to raise_error(Timeliness::Definitions::FormatNotFound)
31 | end
32 | end
33 |
34 | after do
35 | definitions.time_formats = @default_formats
36 | definitions.compile_formats
37 | end
38 | end
39 |
40 | context "remove_formats" do
41 | before do
42 | @default_formats = definitions.time_formats.dup
43 | end
44 |
45 | it "should remove a single format from the formats array for type" do
46 | definitions.remove_formats(:time, 'h.nn_ampm')
47 | expect(definitions.time_formats).not_to include('h.nn_ampm')
48 | end
49 |
50 | it "should remove multiple formats from formats array for type" do
51 | definitions.remove_formats(:time, 'h:nn', 'h.nn_ampm')
52 | expect(definitions.time_formats).not_to include('h:nn')
53 | expect(definitions.time_formats).not_to include('h.nn_ampm')
54 | end
55 |
56 | it "should prevent parsing of removed format" do
57 | should_parse('2.12am', :time)
58 | definitions.remove_formats(:time, 'h.nn_ampm')
59 | should_not_parse('2.12am', :time)
60 | end
61 |
62 | it "should raise error if format does not exist" do
63 | expect { definitions.remove_formats(:time, "ss:hh:nn") }.to raise_error(Timeliness::Definitions::FormatNotFound)
64 | end
65 |
66 | after do
67 | definitions.time_formats = @default_formats
68 | definitions.compile_formats
69 | end
70 | end
71 |
72 | context "use_euro_formats" do
73 | it "should allow ambiguous date to be parsed as European format" do
74 | expect(parser._parse('01/02/2000', :date)).to eq [2000,1,2,nil,nil,nil,nil,nil]
75 | definitions.use_euro_formats
76 | expect(parser._parse('01/02/2000', :date)).to eq [2000,2,1,nil,nil,nil,nil,nil]
77 | end
78 |
79 | it "should not parse formats on switch to euro after initial compile" do
80 | definitions.compile_formats
81 | expect(Timeliness::FormatSet).not_to receive(:compile)
82 | definitions.use_euro_formats
83 | end
84 | end
85 |
86 | context "use_us_formats" do
87 | before do
88 | definitions.use_euro_formats
89 | end
90 |
91 | it "should allow ambiguous date to be parsed as European format" do
92 | expect(parser._parse('01/02/2000', :date)).to eq [2000,2,1,nil,nil,nil,nil,nil]
93 | definitions.use_us_formats
94 | expect(parser._parse('01/02/2000', :date)).to eq [2000,1,2,nil,nil,nil,nil,nil]
95 | end
96 |
97 | it "should not parse formats on switch to euro after initial compile" do
98 | definitions.compile_formats
99 | expect(Timeliness::FormatSet).not_to receive(:compile)
100 | definitions.use_us_formats
101 | end
102 | end
103 |
104 | context "thread safe ambiguous date format switching" do
105 | let(:ambiguous_date) { "01/02/2000" }
106 |
107 | around do |example|
108 | Timeliness::Definitions.compile_formats
109 | example.call
110 | Timeliness::Definitions.compile_formats
111 | end
112 |
113 | it "should allow independent control in current thread" do
114 | threads = {
115 | euro: Thread.new { Timeliness.use_euro_formats; sleep(0.005); Timeliness.parse(ambiguous_date) },
116 | us: Thread.new { sleep(0.001); Timeliness.use_us_formats; Timeliness.parse(ambiguous_date) }
117 | }
118 | threads.values.each { |t| t.join }
119 |
120 | expect(threads[:euro].value).to eql(Time.new(2000,2,1))
121 | expect(threads[:us].value).to eql(Time.new(2000,1,2))
122 | end
123 |
124 | it 'should use default format in new threads' do
125 | Timeliness.configuration.ambiguous_date_format = :euro
126 |
127 | thread = Thread.new { sleep(0.001); Timeliness.parse(ambiguous_date) }
128 | thread.join
129 |
130 | expect(thread.value).to eql(Time.new(2000,2,1))
131 | end
132 | end
133 | end
134 |
--------------------------------------------------------------------------------
/spec/timeliness/format_set_spec.rb:
--------------------------------------------------------------------------------
1 | describe Timeliness::FormatSet do
2 | context "#compile!" do
3 | let(:set) { Timeliness::FormatSet.new(['yyyy-mm-dd', 'dd/mm/yyyy']) }
4 |
5 | it 'should set the regexp for the set' do
6 | set.compile!
7 | expect(set.regexp).not_to be_nil
8 | end
9 | end
10 |
11 | context "compiled regexp" do
12 | context "for time formats" do
13 | format_tests = {
14 | 'hh:nn:ss' => {pass: ['12:12:12', '01:01:01'], fail: ['1:12:12', '12:1:12', '12:12:1', '12-12-12']},
15 | 'hh-nn-ss' => {pass: ['12-12-12', '01-01-01'], fail: ['1-12-12', '12-1-12', '12-12-1', '12:12:12']},
16 | 'h:nn' => {pass: ['12:12', '1:01'], fail: ['12:2', '12-12']},
17 | 'h.nn' => {pass: ['2.12', '12.12'], fail: ['2.1', '12:12']},
18 | 'h nn' => {pass: ['2 12', '12 12'], fail: ['2 1', '2.12', '12:12']},
19 | 'h-nn' => {pass: ['2-12', '12-12'], fail: ['2-1', '2.12', '12:12']},
20 | 'h:nn_ampm' => {pass: ['2:12am', '2:12 pm', '2:12 AM', '2:12PM'], fail: ['1:2am', '1:12 pm', '2.12am']},
21 | 'h.nn_ampm' => {pass: ['2.12am', '2.12 pm'], fail: ['1:2am', '1:12 pm', '2:12am']},
22 | 'h nn_ampm' => {pass: ['2 12am', '2 12 pm'], fail: ['1 2am', '1 12 pm', '2:12am']},
23 | 'h-nn_ampm' => {pass: ['2-12am', '2-12 pm'], fail: ['1-2am', '1-12 pm', '2:12am']},
24 | 'h_ampm' => {pass: ['2am', '2 am', '12 pm'], fail: ['1.am', '12 pm', '2:12am']},
25 | }
26 | format_tests.each do |format, values|
27 | it "should correctly match times in format '#{format}'" do
28 | regexp = compile_regexp(format)
29 | values[:pass].each {|value| expect(value).to match(regexp)}
30 | values[:fail].each {|value| expect(value).not_to match(regexp)}
31 | end
32 | end
33 | end
34 |
35 | context "for date formats" do
36 | format_tests = {
37 | 'yyyy/mm/dd' => {pass: ['2000/02/01'], fail: ['2000\02\01', '2000/2/1', '00/02/01']},
38 | 'yyyy-mm-dd' => {pass: ['2000-02-01'], fail: ['2000\02\01', '2000-2-1', '00-02-01']},
39 | 'yyyy.mm.dd' => {pass: ['2000.02.01'], fail: ['2000\02\01', '2000.2.1', '00.02.01']},
40 | 'm/d/yy' => {pass: ['2/1/01', '02/01/00', '02/01/2000'], fail: ['2/1/0', '2.1.01']},
41 | 'd/m/yy' => {pass: ['1/2/01', '01/02/00', '01/02/2000'], fail: ['1/2/0', '1.2.01']},
42 | 'm\d\yy' => {pass: ['2\1\01', '2\01\00', '02\01\2000'], fail: ['2\1\0', '2/1/01']},
43 | 'd\m\yy' => {pass: ['1\2\01', '1\02\00', '01\02\2000'], fail: ['1\2\0', '1/2/01']},
44 | 'd-m-yy' => {pass: ['1-2-01', '1-02-00', '01-02-2000'], fail: ['1-2-0', '1/2/01']},
45 | 'd.m.yy' => {pass: ['1.2.01', '1.02.00', '01.02.2000'], fail: ['1.2.0', '1/2/01']},
46 | 'd mmm yy' => {pass: ['1 Feb 00', '1 Feb 2000', '1 February 00', '01 February 2000'],
47 | fail: ['1 Fe 00', 'Feb 1 2000', '1 Feb 0']}
48 | }
49 | format_tests.each do |format, values|
50 | it "should correctly match dates in format '#{format}'" do
51 | regexp = compile_regexp(format)
52 | values[:pass].each {|value| expect(value).to match(regexp)}
53 | values[:fail].each {|value| expect(value).not_to match(regexp)}
54 | end
55 | end
56 | end
57 |
58 | context "for datetime formats" do
59 | format_tests = {
60 | 'ddd mmm d hh:nn:ss zo yyyy' => {pass: ['Sat Jul 19 12:00:00 +1000 2008'], fail: []},
61 | 'ddd mmm d hh:nn:ss tz yyyy' => {pass: ['Sat Jul 19 12:00:00 EST 2008'], fail: []},
62 | 'yyyy-mm-ddThh:nn:sszo' => {pass: ['2008-07-19T12:00:00+10:00'], fail: ['2008-07-19T12:00:00Z+10:00']},
63 | 'yyyy-mm-ddThh:nn:ss.uzt' => {pass: ['2019-06-07T03:35:55.100000Z'], fail: []},
64 | 'dd.mm.yyyy hh:nn' => {pass: ['07.06.2019 03:35'], fail: []},
65 | 'yyyy-mm-dd hh:nn' => {pass: ['2019-06-07 03:35'], fail: []},
66 | 'yyyy-mm-ddThh:nn' => {pass: ['2019-06-07T03:35'], fail: []},
67 | }
68 | format_tests.each do |format, values|
69 | it "should correctly match datetimes in format '#{format}'" do
70 | regexp = compile_regexp(format)
71 | values[:pass].each {|value| expect(value).to match(regexp)}
72 | values[:fail].each {|value| expect(value).not_to match(regexp)}
73 | end
74 | end
75 | end
76 | end
77 |
78 | context "#match" do
79 | let(:set) { Timeliness::FormatSet.compile(['yyyy-mm-dd', 'dd/mm/yyyy']) }
80 |
81 | it 'should return array if string matches a format in set' do
82 | expect(set.match('2000-01-02')).to be_kind_of(Array)
83 | end
84 |
85 | it 'should return nil if string does not matches a format in set' do
86 | expect(set.match('2nd Feb 2000')).to be_nil
87 | end
88 |
89 | it 'should only use specific format string for match if provided' do
90 | expect(set.match('2000-01-02', 'yyyy-mm-dd')).to be_kind_of(Array)
91 | expect(set.match('2000-01-02', 'dd/mm/yyyy')).to be_nil
92 | end
93 |
94 | it 'should compile unknown format for one off match' do
95 | expect(set.match('20001011')).to be_nil
96 | expect(set.match('20001011', 'yyyymmdd')).to be_kind_of(Array)
97 | end
98 | end
99 |
100 | def compile_regexp(format)
101 | Timeliness::FormatSet.compile([format]).regexp
102 | end
103 |
104 | end
105 |
--------------------------------------------------------------------------------
/spec/timeliness/format_spec.rb:
--------------------------------------------------------------------------------
1 | describe Timeliness::Format do
2 | describe "#compile!" do
3 | it 'should compile valid string format' do
4 | expect {
5 | Timeliness::Format.new('yyyy-mm-dd hh:nn:ss.u zo').compile!
6 | }.to_not raise_error
7 | end
8 |
9 | it 'should return self' do
10 | format = Timeliness::Format.new('yyyy-mm-dd hh:nn:ss.u zo')
11 | expect(format.compile!).to eq format
12 | end
13 |
14 | it 'should raise compilation error for bad format' do
15 | expect {
16 | Timeliness::Format.new('|--[)').compile!
17 | }.to raise_error(Timeliness::Format::CompilationFailed)
18 | end
19 |
20 | it 'should raise compilation error if token with captured arg is present more than once' do
21 | expect {
22 | Timeliness::Format.new('dd-mm-yyyy-dd').compile!
23 | }.to raise_error(Timeliness::Format::CompilationFailed)
24 | end
25 | end
26 |
27 | describe "#process" do
28 | it "should define method which outputs date array with values in correct order" do
29 | expect(format_for('yyyy-mm-dd').process('2000', '1', '2')).to eq [2000,1,2,nil,nil,nil,nil,nil]
30 | end
31 |
32 | it "should define method which outputs date array from format with different order" do
33 | expect(format_for('dd/mm/yyyy').process('2', '1', '2000')).to eq [2000,1,2,nil,nil,nil,nil,nil]
34 | end
35 |
36 | it "should define method which outputs date array with zeros when month and day are '0'" do
37 | expect(format_for('m/d/yy').process('0', '0', '0000')).to eq [0,0,0,nil,nil,nil,nil,nil]
38 | end
39 |
40 | it "should define method which outputs date array with zeros when month and day are '00'" do
41 | expect(format_for('m/d/yy').process('00', '00', '0000')).to eq [0,0,0,nil,nil,nil,nil,nil]
42 | end
43 |
44 | it "should define method which outputs time array" do
45 | expect(format_for('hh:nn:ss').process('01', '02', '03')).to eq [nil,nil,nil,1,2,3,nil,nil]
46 | end
47 |
48 | it "should define method which outputs time array with meridian 'pm' adjusted hour" do
49 | expect(format_for('hh:nn:ss ampm').process('01', '02', '03', 'pm')).to eq [nil,nil,nil,13,2,3,nil,nil]
50 | end
51 |
52 | it "should define method which outputs time array with meridian 'am' unadjusted hour" do
53 | expect(format_for('hh:nn:ss ampm').process('01', '02', '03', 'am')).to eq [nil,nil,nil,1,2,3,nil,nil]
54 | end
55 |
56 | it "should define method which outputs time array with microseconds" do
57 | expect(format_for('hh:nn:ss.u').process('01', '02', '03', '99')).to eq [nil,nil,nil,1,2,3,990000,nil]
58 | end
59 |
60 | it "should define method which outputs datetime array with zone offset" do
61 | expect(format_for('yyyy-mm-dd hh:nn:ss.u zo').process('2001', '02', '03', '04', '05', '06', '99', '+10:00')).to eq [2001,2,3,4,5,6,990000,36000]
62 | end
63 |
64 | it "should define method which outputs datetime array with zone offset" do
65 | expect(format_for('yyyy-mm-dd hh:nn:ss.u zo').process('2001', '02', '03', '04', '05', '06', '99', '+10:00')).to eq [2001,2,3,4,5,6,990000,36000]
66 | end
67 |
68 | it "should define method which outputs datetime array with timezone string" do
69 | expect(format_for('yyyy-mm-dd hh:nn:ss.u tz').process('2001', '02', '03', '04', '05', '06', '99', 'EST')).to eq [2001,2,3,4,5,6,990000,'EST']
70 | end
71 |
72 | it "should define method which outputs datetime array with 0 offset for zulu time ('Z')" do
73 | expect(format_for('yyyy-mm-dd hh:nn:ss.uzt').process('2001', '02', '03', '04', '05', '06', '99', 'Z')).to eq [2001,2,3,4,5,6,990000,0]
74 | end
75 |
76 | context "with long month" do
77 | let(:format) { format_for('dd mmm yyyy') }
78 |
79 | context "with I18n loaded" do
80 | before(:all) do
81 | I18n.locale = :es
82 | I18n.backend.store_translations :es, date: { month_names: %w{ ~ Enero Febrero Marzo } }
83 | I18n.backend.store_translations :es, date: { abbr_month_names: %w{ ~ Ene Feb Mar } }
84 | end
85 |
86 | it 'should parse abbreviated month for current locale to correct value' do
87 | expect(format.process('2', 'Ene', '2000')).to eq [2000,1,2,nil,nil,nil,nil,nil]
88 | end
89 |
90 | it 'should parse full month for current locale to correct value' do
91 | expect(format.process('2', 'Enero', '2000')).to eq [2000,1,2,nil,nil,nil,nil,nil]
92 | end
93 |
94 | context "with upper case month abbreviations" do
95 | before(:all) do
96 | I18n.backend.store_translations :es, date: { abbr_month_names: %w{ ~ ENE FEB MAR } }
97 | end
98 |
99 | it 'should parse abbreviated month for current locale case insensitively' do
100 | expect(format_for('d-mmm-yyyy').process('01', 'mar', '2023')).to eq [2023,3,1,nil,nil,nil,nil,nil]
101 | end
102 | end
103 |
104 | context "with upper case month names" do
105 | before(:all) do
106 | I18n.backend.store_translations :es, date: { month_names: %w{ ~ ENERO FEBRERO MARZO } }
107 | end
108 |
109 | it 'should parse full month for current locale case insensitively' do
110 | expect(format_for('d-mmm-yyyy').process('01', 'mArZo', '2023')).to eq [2023,3,1,nil,nil,nil,nil,nil]
111 | end
112 | end
113 |
114 | after(:all) do
115 | I18n.locale = :en
116 | end
117 | end
118 |
119 | context "without I18n loaded" do
120 | before do
121 | allow(format).to receive(:i18n_loaded?).and_return(false)
122 | expect(I18n).not_to receive(:t)
123 | end
124 |
125 | it 'should parse abbreviated month to correct value' do
126 | expect(format.process('2', 'Jan', '2000')).to eq [2000,1,2,nil,nil,nil,nil,nil]
127 | end
128 |
129 | it 'should parse full month to correct value' do
130 | expect(format.process('2', 'January', '2000')).to eq [2000,1,2,nil,nil,nil,nil,nil]
131 | end
132 | end
133 | end
134 | end
135 |
136 | def format_for(format)
137 | Timeliness::Format.new(format).compile!
138 | end
139 | end
140 |
--------------------------------------------------------------------------------
/lib/timeliness/parser.rb:
--------------------------------------------------------------------------------
1 | module Timeliness
2 | module Parser
3 | class MissingTimezoneSupport < StandardError; end
4 |
5 | class << self
6 |
7 | def parse(value, type=nil, **options)
8 | return value if acts_like_temporal?(value)
9 | return nil unless parseable?(value)
10 |
11 | if type && !type.is_a?(Symbol)
12 | options[:now] = type if type
13 | type = nil
14 | end
15 |
16 | time_array = _parse(value, type, options)
17 | return nil if time_array.nil?
18 |
19 | default_values_by_type(time_array, type, options) unless type == :datetime
20 |
21 | make_time(time_array, options[:zone])
22 | rescue NoMethodError => ex
23 | raise ex unless ex.message =~ /undefined method `(zone|use_zone|current)' for Time:Class/
24 | raise MissingTimezoneSupport, "ActiveSupport timezone support must be loaded to use timezones other than :utc and :local."
25 | end
26 |
27 | def make_time(time_array, zone_option=nil)
28 | return nil unless fast_date_valid_with_fallback(time_array[0], time_array[1], time_array[2])
29 |
30 | zone, offset = zone_and_offset(time_array[7]) if time_array[7]
31 |
32 | value = create_time_in_zone(time_array[0..6].compact, zone || zone_option)
33 | value = shift_time_to_zone(value, zone_option) if zone
34 |
35 | return nil unless value
36 |
37 | offset ? value + (value.utc_offset - offset) : value
38 | rescue ArgumentError, TypeError
39 | nil
40 | end
41 |
42 | def _parse(string, type=nil, options={})
43 | if options[:strict] && type
44 | Definitions.send("#{type}_format_set").match(string, options[:format])
45 | else
46 | values = nil
47 | Definitions.format_sets(type, string).any? { |set| values = set.match(string, options[:format]) }
48 | values
49 | end
50 | rescue
51 | nil
52 | end
53 |
54 | private
55 |
56 | def parseable?(value)
57 | value.is_a?(String)
58 | end
59 |
60 | def acts_like_temporal?(value)
61 | value.is_a?(Time) || value.is_a?(Date) || value.respond_to?(:acts_like_date?) || value.respond_to?(:acts_like_time?)
62 | end
63 |
64 | def type_and_options_from_args(args)
65 | options = args.last.is_a?(Hash) ? args.pop : {}
66 | type_or_now = args.first
67 | if type_or_now.is_a?(Symbol)
68 | type = type_or_now
69 | elsif type_or_now
70 | options[:now] = type_or_now
71 | end
72 | return type, options
73 | end
74 |
75 | def default_values_by_type(values, type, options)
76 | case type
77 | when :date
78 | values.fill(nil, 3..7)
79 | when :time
80 | current_date = current_date(options)
81 | values[0] = current_date[0]
82 | values[1] = current_date[1]
83 | values[2] = current_date[2]
84 | when nil
85 | dummy_date = current_date(options)
86 | values[0] ||= dummy_date[0]
87 | values[1] ||= dummy_date[1] unless values.values_at(0,2).all?
88 | values[2] ||= dummy_date[2]
89 | end
90 | end
91 |
92 | def current_date(options)
93 | now = if options[:now]
94 | options[:now]
95 | elsif options[:zone]
96 | current_time_in_zone(options[:zone])
97 | else
98 | evaluate_date_for_time_type
99 | end
100 | now.is_a?(Array) ? now[0..2] : [now.year, now.month, now.day]
101 | end
102 |
103 | def current_time_in_zone(zone)
104 | case zone
105 | when :utc
106 | Time.now.getutc
107 | when :local
108 | Time.now.getlocal
109 | when :current
110 | Time.current
111 | else
112 | Time.use_zone(zone) { Time.current }
113 | end
114 | end
115 |
116 | def shift_time_to_zone(time, zone=nil)
117 | zone ||= Timeliness.configuration.default_timezone
118 | case zone
119 | when :utc
120 | time.getutc
121 | when :local
122 | time.getlocal
123 | when :current
124 | time.in_time_zone
125 | else
126 | Time.use_zone(zone) { time.in_time_zone }
127 | end
128 | end
129 |
130 | def create_time_in_zone(time_array, zone=nil)
131 | zone ||= Timeliness.configuration.default_timezone
132 | case zone
133 | when :utc, :local
134 | time_with_datetime_fallback(zone, *time_array)
135 | when :current
136 | Time.zone.local(*time_array)
137 | else
138 | Time.use_zone(zone) { Time.zone.local(*time_array) }
139 | end
140 | end
141 |
142 | def zone_and_offset(parsed_value)
143 | if parsed_value.is_a?(String)
144 | zone = Definitions.timezone_mapping[parsed_value] || parsed_value
145 | else
146 | offset = parsed_value
147 | end
148 | return zone, offset
149 | end
150 |
151 | # Taken from ActiveSupport and simplified
152 | def time_with_datetime_fallback(utc_or_local, year, month=1, day=1, hour=0, min=0, sec=0, usec=0)
153 | return nil if hour > 23 || min > 59 || sec > 59
154 | ::Time.send(utc_or_local, year, month, day, hour, min, sec, usec)
155 | rescue
156 | offset = utc_or_local == :local ? (::Time.local(2007).utc_offset.to_r/86400) : 0
157 | ::DateTime.civil(year, month, day, hour, min, sec, offset)
158 | end
159 |
160 | # Enforce strict date part validity which the Time class does not.
161 | # Only does full date check if month and day are possibly invalid.
162 | def fast_date_valid_with_fallback(year, month, day)
163 | month && month < 13 && (day < 29 || Date.valid_civil?(year, month, day))
164 | end
165 |
166 | def evaluate_date_for_time_type
167 | date_for_time_type = Timeliness.configuration.date_for_time_type
168 |
169 | case date_for_time_type
170 | when Array
171 | date_for_time_type
172 | when Proc
173 | v = date_for_time_type.call
174 | [v.year, v.month, v.day]
175 | end
176 | end
177 |
178 | end
179 |
180 | end
181 | end
182 |
--------------------------------------------------------------------------------
/lib/timeliness/definitions.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Timeliness
4 | module Definitions
5 |
6 | # Format tokens:
7 | # y = year
8 | # m = month
9 | # d = day
10 | # h = hour
11 | # n = minute
12 | # s = second
13 | # u = micro-seconds
14 | # ampm = meridian (am or pm) with or without dots (e.g. am, a.m, or a.m.)
15 | # _ = optional space
16 | # tz = Timezone abbreviation (e.g. UTC, GMT, PST, EST)
17 | # zo = Timezone offset (e.g. +10:00, -08:00, +1000)
18 | #
19 | # All other characters are considered literal. You can embed regexp in the
20 | # format but no guarantees that it will remain intact. If you don't use capture
21 | # groups, dots or backslashes in the regexp, it may well work as expected.
22 | # For special characters, use POSIX character classes for safety.
23 | #
24 | # Repeating tokens:
25 | # x = 1 or 2 digits for unit (e.g. 'h' means an hour can be '9' or '09')
26 | # xx = 2 digits exactly for unit (e.g. 'hh' means an hour can only be '09')
27 | #
28 | # Special Cases:
29 | # yy = 2 or 4 digit year
30 | # yyyy = exactly 4 digit year
31 | # mmm = month long name (e.g. 'Jul' or 'July')
32 | # ddd = Day name of 3 to 9 letters (e.g. Wed or Wednesday)
33 | # u = microseconds matches 1 to 6 digits
34 |
35 | @time_formats = [
36 | 'hh:nn:ss',
37 | 'hh-nn-ss',
38 | 'h:nn',
39 | 'h.nn',
40 | 'h nn',
41 | 'h-nn',
42 | 'h:nn_ampm',
43 | 'h.nn_ampm',
44 | 'h nn_ampm',
45 | 'h-nn_ampm',
46 | 'h_ampm'
47 | ]
48 |
49 | @date_formats = [
50 | 'yyyy-mm-dd',
51 | 'yyyy/mm/dd',
52 | 'yyyy.mm.dd',
53 | 'm/d/yy',
54 | 'd/m/yy',
55 | 'm\d\yy',
56 | 'd\m\yy',
57 | 'd-m-yy',
58 | 'dd-mm-yyyy',
59 | 'd.m.yy',
60 | 'd mmm yy'
61 | ]
62 |
63 | @datetime_formats = [
64 | 'yyyy-mm-dd hh:nn:ss.u',
65 | 'yyyy-mm-dd hh:nn:ss',
66 | 'yyyy-mm-dd h:nn',
67 | 'yyyy-mm-dd h:nn_ampm',
68 | 'm/d/yy h:nn:ss',
69 | 'm/d/yy h:nn_ampm',
70 | 'm/d/yy h:nn',
71 | 'd/m/yy hh:nn:ss',
72 | 'd/m/yy h:nn_ampm',
73 | 'd/m/yy h:nn',
74 | 'dd-mm-yyyy hh:nn:ss',
75 | 'dd-mm-yyyy h:nn_ampm',
76 | 'dd-mm-yyyy h:nn',
77 | 'dd.mm.yyyy hh:nn:ss',
78 | 'dd.mm.yyyy h:nn',
79 | 'ddd, dd mmm yyyy hh:nn:ss tz', # RFC 822
80 | 'ddd, dd mmm yyyy hh:nn:ss zo', # RFC 822
81 | 'ddd mmm d hh:nn:ss zo yyyy', # Ruby time string
82 | 'yyyy-mm-ddThh:nn', # ISO 8601 without seconds
83 | 'yyyy-mm-ddThh:nn:ss', # ISO 8601
84 | 'yyyy-mm-ddThh:nn:sszo', # ISO 8601 with zone offset
85 | 'yyyy-mm-ddThh:nn:sszt', # ISO 8601 with 'Zulu time' (i.e. Z) UTC zone designator
86 | 'yyyy-mm-ddThh:nn:ss.u', # ISO 8601 with usec
87 | 'yyyy-mm-ddThh:nn:ss.uzo', # ISO 8601 with usec and offset
88 | 'yyyy-mm-ddThh:nn:ss.uzt', # ISO 8601 with usec and 'Zulu time' (i.e. Z) UTC zone designator
89 | 'yyyy-mm-dd hh:nn:ss zo', # Ruby time string in later versions
90 | 'yyyy-mm-dd hh:nn:ss tz', # Ruby time string for UTC in later versions
91 | ]
92 |
93 | # All tokens available for format construction. The token array is made of
94 | # regexp and key for format component mapping, if any.
95 | #
96 | @format_tokens = {
97 | 'ddd' => [ '\w{3,9}' ],
98 | 'dd' => [ '\d{2}', :day ],
99 | 'd' => [ '\d{1,2}', :day ],
100 | 'mmm' => [ '\w{3,9}', :month ],
101 | 'mm' => [ '\d{2}', :month ],
102 | 'm' => [ '\d{1,2}', :month ],
103 | 'yyyy' => [ '\d{4}', :year ],
104 | 'yy' => [ '\d{4}|\d{2}', :year ],
105 | 'hh' => [ '\d{2}', :hour ],
106 | 'h' => [ '\d{1,2}', :hour ],
107 | 'nn' => [ '\d{2}', :min ],
108 | 'n' => [ '\d{1,2}', :min ],
109 | 'ss' => [ '\d{2}', :sec ],
110 | 's' => [ '\d{1,2}', :sec ],
111 | 'u' => [ '\d{1,6}', :usec ],
112 | 'ampm' => [ '[aApP]\.?[mM]\.?', :meridian ],
113 | 'zo' => [ '[+-]\d{2}:?\d{2}', :offset ],
114 | 'tz' => [ '[A-Z]{1,5}', :zone ],
115 | 'zt' => [ '[Z]{1}', :zulu],
116 | '_' => [ '\s?' ]
117 | }
118 |
119 | # Component argument values will be passed to the format method if matched in
120 | # the time string. The key should match the key defined in the format tokens.
121 | #
122 | # The array consists of the position the value should be inserted in
123 | # the time array, and the code to place in the time array.
124 | #
125 | # If the position is nil, then the value won't be put in the time array. If the
126 | # code is nil, then just the raw value is used.
127 | #
128 | @format_components = {
129 | year: [ 0, 'unambiguous_year(year)'],
130 | month: [ 1, 'month_index(month)'],
131 | day: [ 2 ],
132 | hour: [ 3, 'full_hour(hour, meridian ||= nil)'],
133 | min: [ 4 ],
134 | sec: [ 5 ],
135 | usec: [ 6, 'microseconds(usec)'],
136 | offset: [ 7, 'offset_in_seconds(offset)'],
137 | zone: [ 7, 'zone'],
138 | zulu: [ 7, 'offset_in_seconds("00:00")'],
139 | meridian: [ nil ]
140 | }
141 |
142 | # Mapping some common timezone abbreviations which are not mapped or
143 | # mapped inconsistenly in ActiveSupport (TzInfo).
144 | #
145 | @timezone_mapping = {
146 | 'AEST' => 'Australia/Sydney',
147 | 'AEDT' => 'Australia/Sydney',
148 | 'ACST' => 'Australia/Adelaide',
149 | 'ACDT' => 'Australia/Adelaide',
150 | 'PST' => 'PST8PDT',
151 | 'PDT' => 'PST8PDT',
152 | 'CST' => 'CST6CDT',
153 | 'CDT' => 'CST6CDT',
154 | 'EDT' => 'EST5EDT',
155 | 'MDT' => 'MST7MDT'
156 | }
157 |
158 | US_FORMAT_REGEXP = /\Am{1,2}[^m]/
159 | FormatNotFound = Class.new(StandardError)
160 | DuplicateFormat = Class.new(StandardError)
161 |
162 | class << self
163 | attr_accessor :time_formats, :date_formats, :datetime_formats, :format_tokens, :format_components, :timezone_mapping
164 | attr_reader :time_format_set, :date_format_set, :datetime_format_set
165 |
166 | # Adds new formats. Must specify format type and can specify a :before
167 | # option to nominate which format the new formats should be inserted in
168 | # front on to take higher precedence.
169 | #
170 | # Error is raised if format already exists or if :before format is not found.
171 | #
172 | def add_formats(type, *add_formats)
173 | formats = send("#{type}_formats")
174 | options = add_formats.last.is_a?(Hash) ? add_formats.pop : {}
175 | before = options[:before]
176 | raise FormatNotFound, "Format for :before option #{before.inspect} was not found." if before && !formats.include?(before)
177 |
178 | add_formats.each do |format|
179 | raise DuplicateFormat, "Format #{format.inspect} is already included in #{type.inspect} formats" if formats.include?(format)
180 |
181 | index = before ? formats.index(before) : -1
182 | formats.insert(index, format)
183 | end
184 | compile_formats
185 | end
186 |
187 | # Delete formats of specified type. Error raised if format not found.
188 | #
189 | def remove_formats(type, *remove_formats)
190 | remove_formats.each do |format|
191 | unless send("#{type}_formats").delete(format)
192 | raise FormatNotFound, "Format #{format.inspect} not found in #{type.inspect} formats"
193 | end
194 | end
195 | compile_formats
196 | end
197 |
198 | def current_date_format=(value)
199 | Thread.current["Timeliness.current_date_format"] = value
200 | end
201 |
202 | def current_date_format
203 | Thread.current["Timeliness.current_date_format"] ||= Timeliness.configuration.ambiguous_date_format
204 | end
205 |
206 | # Get date format set for using current thread format setting
207 | #
208 | def date_format_set
209 | instance_variable_get(:"@#{current_date_format}_date_format_set")
210 | end
211 |
212 | # Get datetime format set for using current thread format setting
213 | #
214 | def datetime_format_set
215 | instance_variable_get(:"@#{current_date_format}_datetime_format_set")
216 | end
217 |
218 | # Use date formats that return ambiguous dates parsed in European format
219 | #
220 | def use_euro_formats
221 | self.current_date_format = :euro
222 | end
223 |
224 | # Use date formats that return ambiguous dates parsed as US format
225 | #
226 | def use_us_formats
227 | self.current_date_format = :us
228 | end
229 |
230 | def compile_formats
231 | @sorted_token_keys = nil
232 |
233 | @time_format_set = FormatSet.compile(time_formats)
234 | @us_date_format_set = FormatSet.compile(date_formats)
235 | @us_datetime_format_set = FormatSet.compile(datetime_formats)
236 | @euro_date_format_set = FormatSet.compile(date_formats.select { |format| US_FORMAT_REGEXP !~ format })
237 | @euro_datetime_format_set = FormatSet.compile(datetime_formats.select { |format| US_FORMAT_REGEXP !~ format })
238 | end
239 |
240 | def sorted_token_keys
241 | @sorted_token_keys ||= format_tokens.keys.sort_by(&:size).reverse
242 | end
243 |
244 | # Returns format for type and other possible matching format set based on type
245 | # and value length. Gives minor speed-up by checking string length.
246 | #
247 | def format_sets(type, string)
248 | case type
249 | when :date
250 | [ date_format_set, datetime_format_set ]
251 | when :datetime
252 | if string.length < 11
253 | [ date_format_set, datetime_format_set ]
254 | else
255 | [ datetime_format_set, date_format_set ]
256 | end
257 | when :time
258 | if string.length < 11
259 | [ time_format_set ]
260 | else
261 | [ datetime_format_set, time_format_set ]
262 | end
263 | else
264 | if string.length < 11
265 | [ date_format_set, time_format_set, datetime_format_set ]
266 | else
267 | [ datetime_format_set, date_format_set, time_format_set ]
268 | end
269 | end
270 | end
271 |
272 | end
273 | end
274 | end
275 |
--------------------------------------------------------------------------------
/README.rdoc:
--------------------------------------------------------------------------------
1 | = Timeliness {rdoc-image:https://github.com/adzap/timeliness/actions/workflows/ci.yml/badge.svg?branch=master}[https://github.com/adzap/timeliness/actions/workflows/ci.yml]
2 |
3 | * Source: https://github.com/adzap/timeliness
4 | * Bugs: https://github.com/adzap/timeliness/issues
5 |
6 | == Description
7 |
8 | Date/time parser for Ruby with the following features:
9 |
10 | * Extensible with custom formats and tokens.
11 | * It's pretty fast. Up to 60% faster than Time/Date parse method.
12 | * Control the parser strictness.
13 | * Control behaviour of ambiguous date formats (US vs European e.g. mm/dd/yy, dd/mm/yy).
14 | * I18n support (for months), if I18n gem loaded.
15 | * Fewer WTFs than Time/Date parse method.
16 | * Has no dependencies.
17 | * Works with Ruby MRI >= 2.2
18 |
19 | Extracted from the {validates_timeliness gem}[https://github.com/adzap/validates_timeliness], it has been rewritten cleaner and much faster. It's most suitable for when
20 | you need to control the parsing behaviour. It's faster than the Time/Date class parse methods, so it
21 | has general appeal.
22 |
23 |
24 | == Usage
25 |
26 | The simplest example is just a straight forward string parse:
27 |
28 | Timeliness.parse('2010-09-08 12:13:14') #=> Wed Sep 08 12:13:14 1000 2010
29 | Timeliness.parse('2010-09-08') #=> Wed Sep 08 00:00:00 1000 2010
30 | Timeliness.parse('12:13:14') #=> Sat Jan 01 12:13:14 1100 2000
31 |
32 |
33 | === Specify a Type
34 |
35 | You can provide a type which will tell the parser that you are only interested in the part of
36 | the value for that type.
37 |
38 | Timeliness.parse('2010-09-08 12:13:14', :date) #=> Wed Sep 08 00:00:00 1000 2010
39 | Timeliness.parse('2010-09-08 12:13:14', :time) #=> Sat Jan 01 12:13:14 1100 2000
40 | Timeliness.parse('2010-09-08 12:13:14', :datetime) #=> Wed Sep 08 12:13:14 1000 2010 i.e. the whole string is used
41 |
42 | Now let's get strict. Pass the :strict option with true and things get finicky
43 |
44 | Timeliness.parse('2010-09-08 12:13:14', :date, strict: true) #=> nil
45 | Timeliness.parse('2010-09-08 12:13:14', :time, strict: true) #=> nil
46 | Timeliness.parse('2010-09-08 12:13:14', :datetime, strict: true) #=> Wed Sep 08 12:13:14 1000 2010 i.e. the whole string is used
47 |
48 | The date and time strings are not accepted for a datetime type. The strict option without a type is
49 | ignored.
50 |
51 |
52 | === Specify the Current Date
53 |
54 | Notice a time only string will return with a date value. The date value can be configured globally
55 | with this setting:
56 |
57 | Timeliness.date_for_time_type = [2010, 1, 1]
58 |
59 | or using a lambda thats evaluated when parsed
60 |
61 | Timeliness.date_for_time_type = lambda { Time.now }
62 |
63 | It can also be specified with :now option:
64 |
65 | Timeliness.parse('12:13:14', now: Time.mktime(2010,9,8)) #=> Wed Sep 08 12:13:14 1000 2010
66 |
67 | As well conforming to the Ruby Time class style.
68 |
69 | Timeliness.parse('12:13:14', Time.mktime(2010,9,8)) #=> Wed Sep 08 12:13:14 1000 2010
70 |
71 | === Timezone
72 |
73 | To control what zone the time object is returned in, you have two options. Firstly you can set the
74 | default zone. Below is the list of options with their effective time creation method call
75 |
76 | Timeliness.default_timezone = :local # Time.local(...)
77 | Timeliness.default_timezone = :utc # Time.utc(...)
78 | Timeliness.default_timezone = :current # Time.zone.local(...). Use current zone.
79 | Timeliness.default_timezone = 'Melbourne' # Time.use_zone('Melbourne') { Time.zone.local(...) }. Doesn't change Time.zone.
80 |
81 | The last two options require that you have ActiveSupport timezone extension loaded.
82 |
83 | You can also use the :zone option to control it for a single parse call:
84 |
85 | Timeliness.parse('2010-09-08 12:13:14', zone: :utc) #=> Wed Sep 08 12:13:14 UTC 2010
86 | Timeliness.parse('2010-09-08 12:13:14', zone: :local) #=> Wed Sep 08 12:13:14 1000 2010
87 | Timeliness.parse('2010-09-08 12:13:14', zone: :current) #=> Wed Sep 08 12:13:14 1000 2010, with Time.zone = 'Melbourne'
88 | Timeliness.parse('2010-09-08 12:13:14', zone: 'Melbourne') #=> Wed Sep 08 12:13:14 1000 2010
89 |
90 | Remember, you must have ActiveSupport timezone extension loaded to use the last two examples.
91 |
92 |
93 | === Restrict to Format
94 |
95 | To get super finicky, you can restrict the parsing to a single format with the :format option
96 |
97 | Timeliness.parse('2010-09-08 12:13:14', format: 'yyyy-mm-dd hh:nn:ss') #=> Wed Sep 08 12:13:14 UTC 2010
98 | Timeliness.parse('08/09/2010 12:13:14', format: 'yyyy-mm-dd hh:nn:ss') #=> nil
99 |
100 |
101 | === String with Offset or Zone Abbreviations
102 |
103 | Sometimes you may want to parse a string with a zone abbreviation (e.g. MST) or the zone offset (e.g. +1000).
104 | These values are supported by the parser and will be used when creating the time object. The return value
105 | will be in the default timezone or the zone specified with the :zone option.
106 |
107 | Timeliness.parse('Wed, 08 Sep 2010 12:13:14 MST') => Thu, 09 Sep 2010 05:13:14 EST 10:00
108 |
109 | Timeliness.parse('2010-09-08T12:13:14-06:00') => Thu, 09 Sep 2010 05:13:14 EST 10:00
110 |
111 | To enable zone abbreviations to work you must have loaded ActiveSupport.
112 |
113 | The zone abbreviations supported are those defined in the TzInfo gem, used by ActiveSupport. If you find some
114 | that are missing you can add more:
115 |
116 | Timeliness.timezone_mapping.update(
117 | 'ZZZ' => 'Sleepy Town'
118 | )
119 |
120 | Where 'Sleepy Town' is a valid zone name supported by ActiveSupport/TzInfo.
121 |
122 |
123 | === Raw Parsed Values
124 |
125 | If you would like to get the raw array of values before the time object is created, you can with
126 |
127 | Timeliness._parse('2010-09-08 12:13:14.123456 MST') # => [2010, 9, 8, 12, 13, 14, 123456, 'MST']
128 |
129 | The last two value are the microseconds, and zone abbreviation or offset.
130 | Note: The format for this value is not defined. You can add it yourself, easily.
131 |
132 |
133 | === ActiveSupport Core Extensions
134 |
135 | To make it easier to use the parser in Rails or an app using ActiveSupport, you can add/override the methods
136 | for to_time, to_date and to_datetime on a string value. These methods will then use
137 | the Timeliness parser for converting a string, instead of the default.
138 |
139 | You just need to add this line to an initializer or other application file:
140 |
141 | require 'timeliness/core_ext'
142 |
143 |
144 | == Formats
145 |
146 | The gem has default formats included which can be easily added to using the format syntax. Also
147 | formats can be easily removed so that they are no longer considered valid.
148 |
149 | Below are the default formats. If you think they are easy to read then you will be happy to know
150 | that is exactly the same format syntax you can use to define your own. No complex regular
151 | expressions are needed.
152 |
153 |
154 | === Datetime formats
155 |
156 | m/d/yy h:nn:ss OR d/m/yy hh:nn:ss
157 | m/d/yy h:nn OR d/m/yy h:nn
158 | m/d/yy h:nn_ampm OR d/m/yy h:nn_ampm
159 | yyyy-mm-dd hh:nn:ss
160 | yyyy-mm-dd h:nn
161 | ddd mmm d hh:nn:ss zo yyyy # Ruby time string
162 | yyyy-mm-ddThh:nn:ssZ # ISO 8601 without zone offset
163 | yyyy-mm-ddThh:nn:sszo # ISO 8601 with zone offset
164 |
165 | NOTE: To use non-US date formats see US/Euro Formats section
166 |
167 |
168 | === Date formats
169 |
170 | yyyy/mm/dd
171 | yyyy-mm-dd
172 | yyyy.mm.dd
173 | m/d/yy OR d/m/yy
174 | m\d\yy OR d\m\yy
175 | d-m-yy
176 | dd-mm-yyyy
177 | d.m.yy
178 | d mmm yy
179 |
180 | NOTE: To use non-US date formats see US/Euro Formats section
181 |
182 |
183 | === Time formats
184 |
185 | hh:nn:ss
186 | hh-nn-ss
187 | h:nn
188 | h.nn
189 | h nn
190 | h-nn
191 | h:nn_ampm
192 | h.nn_ampm
193 | h nn_ampm
194 | h-nn_ampm
195 | h_ampm
196 |
197 | NOTE: Any time format without a meridian token (the 'ampm' token) is considered in 24 hour time.
198 |
199 |
200 | === Format Tokens
201 |
202 | Here is what each format token means:
203 |
204 | Format tokens:
205 | y = year
206 | m = month
207 | d = day
208 | h = hour
209 | n = minute
210 | s = second
211 | u = micro-seconds
212 | ampm = meridian (am or pm) with or without dots (e.g. am, a.m, or a.m.)
213 | _ = optional space
214 | tz = Timezone abbreviation (e.g. UTC, GMT, PST, EST)
215 | zo = Timezone offset (e.g. +10:00, -08:00, +1000)
216 |
217 | Repeating tokens:
218 | x = 1 or 2 digits for unit (e.g. 'h' means an hour can be '9' or '09')
219 | xx = 2 digits exactly for unit (e.g. 'hh' means an hour can only be '09')
220 |
221 | Special Cases:
222 | yy = 2 or 4 digit year
223 | yyyy = exactly 4 digit year
224 | mmm = month long name (e.g. 'Jul' or 'July')
225 | ddd = Day name of 3 to 9 letters (e.g. Wed or Wednesday)
226 | u = microseconds matches 1 to 3 digits
227 |
228 | All other characters are considered literal. For the technically minded, these formats are compiled
229 | into a single regular expression
230 |
231 | To see all defined formats look at the {source code}[https://github.com/adzap/timeliness/tree/master/lib/timeliness/formats.rb].
232 |
233 |
234 | == Settings
235 |
236 | === US/Euro Formats
237 |
238 | The perennial problem for non-US developers or applications not primarily for the US, is the US date
239 | format of m/d/yy. This is can be ambiguous with the European format of d/m/yy. By default the gem uses the
240 | US formats as this is the Ruby default
241 | when it does date interpretation.
242 |
243 | To switch to using the European (or Rest of The World) formats use this setting
244 |
245 | Timeliness.use_euro_formats
246 |
247 | Now '01/02/2000' will be parsed as 1st February 2000, instead of 2nd January 2000.
248 |
249 | You can switch back to US formats with
250 |
251 | Timeliness.use_us_formats
252 |
253 | ==== Thread Safety
254 |
255 | The switching of formats is threadsafe (since v0.4.0), however for each new thread the format default will be
256 | the gem default, being the US format. To control default for your app and each new thread, use the config
257 |
258 | Timeliness.ambiguous_date_format = :euro
259 |
260 |
261 | === Customising Formats
262 |
263 | Sometimes you may not want certain formats to be valid. You can remove formats for each type and the
264 | parser will then not consider that a valid format. To remove a format
265 |
266 | Timeliness.remove_formats(:date, 'm\d\yy')
267 |
268 | Adding new formats is also simple
269 |
270 | Timeliness.add_formats(:time, "h o'clock")
271 |
272 | Now "10 o'clock" will be a valid value.
273 |
274 | You can embed regular expressions in the format but no guarantees that it will remain intact. If
275 | you avoid the use of any token characters, and regexp dots or backslashes as special characters in
276 | the regexp, it may work as expected. For special characters use POSIX character classes for safety.
277 | See the ISO 8601 datetime for an example of an embedded regular expression.
278 |
279 | Because formats are evaluated in order, adding a format which may be ambiguous with an existing
280 | format, will mean your format is ignored. If you need to make your new format higher precedence than
281 | an existing format, you can include the before option like so
282 |
283 | Timeliness.add_formats(:time, 'ss:nn:hh', before: 'hh:nn:ss')
284 |
285 | Now a time of '59:30:23' will be interpreted as 11:30:59 pm. This option saves you adding a new one
286 | and deleting an old one to get it to work.
287 |
288 |
289 | === Ambiguous Year
290 |
291 | When dealing with 2 digit year values, by default a year is interpreted as being in the last century
292 | when at or above 30. You can customize this however
293 |
294 | Timeliness.ambiguous_year_threshold = 20
295 |
296 | Now you get:
297 |
298 | year of 19 is considered 2019
299 | year of 20 is considered 1920
300 |
301 |
302 | == Credits
303 |
304 | * Adam Meehan (adam.meehan@gmail.com, https://github.com/adzap)
305 |
306 |
307 | == License
308 |
309 | Copyright (c) 2010 Adam Meehan, released under the MIT license
310 |
--------------------------------------------------------------------------------
/spec/timeliness/parser_spec.rb:
--------------------------------------------------------------------------------
1 | describe Timeliness::Parser do
2 | def self.timezone_settings(zone: nil, output: nil)
3 | before do
4 | Time.zone = zone if zone
5 | Timeliness.configuration.default_timezone = output if output
6 | end
7 | end
8 |
9 | around do |example|
10 | current_zone = Time.zone
11 | example.call
12 | Time.zone = current_zone
13 | end
14 |
15 | before(:all) do
16 | Timecop.freeze(2010,1,1,0,0,0)
17 | end
18 |
19 | describe "parse" do
20 | it "should return Time object for valid datetime string" do
21 | expect(parse("2000-01-01 12:13:14")).to be_kind_of(Time)
22 | end
23 |
24 | it "should return nil for empty string" do
25 | expect(parse("")).to be_nil
26 | end
27 |
28 | it "should return nil for nil value" do
29 | expect(parse(nil)).to be_nil
30 | end
31 |
32 | it "should return same value if value is a Time, Date, or DateTime" do
33 | [Time.now, Date.new, DateTime.new].each do |value|
34 | expect(parse(value)).to eq value
35 | end
36 | end
37 |
38 | it "should return nil for non-string non-temporal values" do
39 | [ {}, [], Class.new ].each do |value|
40 | expect(parse(value)).to eq nil
41 | end
42 | end
43 |
44 | it "should return time object for valid date string" do
45 | expect(parse("2000-01-01")).to be_kind_of(Time)
46 | end
47 |
48 | it "should return nil for invalid date string" do
49 | should_not_parse("2000-02-30")
50 | end
51 |
52 | it "should return nil for invalid date string where month is '0'" do
53 | should_not_parse("0/01/2000")
54 | end
55 |
56 | it "should return nil for invalid date string where month is '00'" do
57 | should_not_parse("00/01/2000")
58 | end
59 |
60 | it "should return nil for invalid date month string" do
61 | should_not_parse("1 Foo 2000")
62 | end
63 |
64 | it "should return time object for valid time string" do
65 | expect(parse("12:13:14")).to be_kind_of(Time)
66 | end
67 |
68 | it "should return nil for invalid time string" do
69 | should_not_parse("25:00:00")
70 | end
71 |
72 | it "should return nil for datetime string with invalid date part" do
73 | should_not_parse("2000-02-30 12:13:14")
74 | end
75 |
76 | it "should return nil for datetime string with invalid time part" do
77 | should_not_parse("2000-02-01 25:13:14")
78 | end
79 |
80 | it 'should return nil for ISO 8601 string with invalid time part' do
81 | should_not_parse("2000-02-01T25:13:14+02:00")
82 | end
83 |
84 | context "string with zone offset value" do
85 | context "when current timezone is later than string zone" do
86 | timezone_settings zone: 'Australia/Melbourne', output: :current
87 |
88 | it 'should return value shifted by positive string offset in default timezone' do
89 | value = parse("2000-06-01T12:00:00+02:00")
90 | expect(value).to eq Time.zone.local(2000,6,1,20,0,0)
91 | expect(value.utc_offset).to eq 10.hours
92 | end
93 |
94 | it 'should return value shifted by negative string offset in default timezone' do
95 | value = parse("2000-06-01T12:00:00-01:00")
96 | expect(value).to eq Time.zone.local(2000,6,1,23,0,0)
97 | expect(value.utc_offset).to eq 10.hours
98 | end
99 | end
100 |
101 | context "when current timezone is earlier than string zone" do
102 | timezone_settings zone: 'America/Phoenix', output: :current
103 |
104 | it 'should return value shifted by positive offset in default timezone' do
105 | value = parse("2000-06-01T12:00:00+02:00")
106 | expect(value).to eq Time.zone.local(2000,6,1,3,0,0)
107 | expect(value.utc_offset).to eq(-7.hours)
108 | end
109 |
110 | it 'should return value shifted by negative offset in default timezone' do
111 | value = parse("2000-06-01T12:00:00-01:00")
112 | expect(value).to eq Time.zone.local(2000,6,1,6,0,0)
113 | expect(value.utc_offset).to eq(-7.hours)
114 | end
115 | end
116 | end
117 |
118 | context "string with zone abbreviation" do
119 | timezone_settings zone: 'Australia/Melbourne'
120 |
121 | it 'should return value using string zone adjusted to default :local timezone' do
122 | Timeliness.configuration.default_timezone = :local
123 |
124 | value = parse("Thu, 01 Jun 2000 03:00:00 MST")
125 | expect(value).to eq Time.utc(2000,6,1,10,0,0).getlocal
126 | expect(value.utc_offset).to eq Time.mktime(2000, 6, 1, 10, 0, 0).utc_offset
127 | end
128 |
129 | it 'should return value using string zone adjusted to default :current timezone' do
130 | Timeliness.configuration.default_timezone = :current
131 | Time.zone = 'Adelaide'
132 |
133 | value = parse("Thu, 01 Jun 2000 03:00:00 MST")
134 | expect(value).to eq Time.zone.local(2000,6,1,19,30,0)
135 | expect(value.utc_offset).to eq 9.5.hours
136 | end
137 |
138 | it 'should return value using string zone adjusted to :zone option string timezone' do
139 | Timeliness.configuration.default_timezone = :local
140 |
141 | value = parse("Thu, 01 Jun 2000 03:00:00 MST", zone: 'Perth')
142 | expect(value).to eq Time.use_zone('Perth') { Time.zone.local(2000,6,1,18,0,0) }
143 | expect(value.utc_offset).to eq 8.hours
144 | end
145 | end
146 |
147 | context "string with zulu time abbreviation 'Z'" do
148 | timezone_settings zone: 'Australia/Melbourne'
149 |
150 | it 'should return value using string zone adjusted to default :current timezone' do
151 | Timeliness.configuration.default_timezone = :current
152 |
153 | value = parse("2000-06-01T12:00:00Z")
154 | expect(value).to eq Time.zone.local(2000,6,1,22,0,0)
155 | expect(value.utc_offset).to eq 10.hours
156 | end
157 | end
158 |
159 | context "with :datetime type" do
160 | it "should return time object for valid datetime string" do
161 | expect(parse("2000-01-01 12:13:14", :datetime)).to eq Time.local(2000,1,1,12,13,14)
162 | end
163 |
164 | it "should return nil for invalid date string" do
165 | expect(parse("0/01/2000", :datetime)).to be_nil
166 | end
167 | end
168 |
169 | context "with :date type" do
170 | it "should return time object for valid date string" do
171 | expect(parse("2000-01-01", :date)).to eq Time.local(2000,1,1)
172 | end
173 |
174 | it "should ignore time in datetime string" do
175 | expect(parse('2000-02-01 12:13', :date)).to eq Time.local(2000,2,1)
176 | end
177 |
178 | it "should return nil for invalid date string" do
179 | expect(parse("0/01/2000", :date)).to be_nil
180 | end
181 | end
182 |
183 | context "with :time type" do
184 | it "should return time object with a dummy date values" do
185 | expect(parse('12:13', :time)).to eq Time.local(2010,1,1,12,13)
186 | end
187 |
188 | it "should ignore date in datetime string" do
189 | expect(parse('2010-02-01 12:13', :time)).to eq Time.local(2010,1,1,12,13)
190 | end
191 |
192 | it "should raise error if time hour is out of range for AM meridian" do
193 | expect(parse('13:14 am', :time)).to be_nil
194 | end
195 | end
196 |
197 | context "with :now option" do
198 | it 'should use date parts if string does not specify' do
199 | time = parse("12:13:14", now: Time.local(2010,1,1))
200 | expect(time).to eq Time.local(2010,1,1,12,13,14)
201 | end
202 | end
203 |
204 | context "with time value argument" do
205 | it 'should use argument as :now option value' do
206 | time = parse("12:13:14", Time.local(2010,1,1))
207 | expect(time).to eq Time.local(2010,1,1,12,13,14)
208 | end
209 | end
210 |
211 | context "with :zone option" do
212 | context ":utc" do
213 | it "should return time object in utc timezone" do
214 | time = parse("2000-06-01 12:13:14", :datetime, zone: :utc)
215 | expect(time.utc_offset).to eq 0
216 | end
217 |
218 | it 'should return nil for partial invalid time component' do
219 | expect(parse("2000-06-01 12:60", :datetime, zone: :utc)).to be_nil
220 | end
221 | end
222 |
223 | context ":local" do
224 | it "should return time object in local system timezone" do
225 | time = parse("2000-06-01 12:13:14", :datetime, zone: :local)
226 | expect(time.utc_offset).to eq Time.mktime(2000, 6, 1, 12, 13, 14).utc_offset
227 | end
228 |
229 | it 'should return nil for partial invalid time component' do
230 | expect(parse("2000-06-01 12:60", :datetime, zone: :local)).to be_nil
231 | end
232 | end
233 |
234 | context ":current" do
235 | timezone_settings zone: 'Adelaide'
236 |
237 | it "should return time object in current timezone" do
238 | time = parse("2000-06-01 12:13:14", :datetime, zone: :current)
239 | expect(time.utc_offset).to eq 9.5.hours
240 | end
241 |
242 | it 'should return nil for partial invalid time component' do
243 | expect(parse("2000-06-01 12:60", :datetime, zone: :current)).to be_nil
244 | end
245 | end
246 |
247 | context "named zone" do
248 | it "should return time object in the timezone" do
249 | time = parse("2000-06-01 12:13:14", :datetime, zone: 'London')
250 | expect(time.utc_offset).to eq 1.hour
251 | end
252 |
253 | it 'should return nil for partial invalid time component' do
254 | expect(parse("2000-06-01 12:60", :datetime, zone: 'London')).to be_nil
255 | end
256 | end
257 |
258 | context "without ActiveSupport loaded" do
259 | it 'should output message' do
260 | expect {
261 | expect(Time).to receive(:zone).and_raise(NoMethodError.new("undefined method `zone' for Time:Class"))
262 | parse("2000-06-01 12:13:14", zone: :current)
263 | }.to raise_error(Timeliness::Parser::MissingTimezoneSupport)
264 |
265 | expect {
266 | expect(Time).to receive(:current).and_raise(NoMethodError.new("undefined method `current' for Time:Class"))
267 | parse("12:13:14", zone: :current)
268 | }.to raise_error(Timeliness::Parser::MissingTimezoneSupport)
269 |
270 | expect {
271 | expect(Time).to receive(:use_zone).and_raise(NoMethodError.new("undefined method `use_zone' for Time:Class"))
272 | parse("2000-06-01 12:13:14", zone: 'London')
273 | }.to raise_error(Timeliness::Parser::MissingTimezoneSupport)
274 | end
275 | end
276 | end
277 |
278 | context "for time type" do
279 | context "with date from date_for_time_type" do
280 | it 'should return date array' do
281 | Timeliness.configuration.date_for_time_type = [2010,1,1]
282 |
283 | expect(parse('12:13:14', :time)).to eq Time.local(2010,1,1,12,13,14)
284 | end
285 |
286 | it 'should return date array evaluated lambda' do
287 | Timeliness.configuration.date_for_time_type = lambda { Time.local(2010,2,1) }
288 |
289 | expect(parse('12:13:14', :time)).to eq Time.local(2010,2,1,12,13,14)
290 | end
291 | end
292 |
293 | context "with :now option" do
294 | it 'should use date from :now' do
295 | expect(parse('12:13:14', :time, now: Time.local(2010, 6, 1))).to eq Time.local(2010,6,1,12,13,14)
296 | end
297 | end
298 |
299 | context "with :zone option" do
300 | before(:all) do
301 | @current_tz = ENV['TZ']
302 | ENV['TZ'] = 'Australia/Melbourne'
303 | Timecop.freeze(2010,1,1,0,0,0)
304 | end
305 |
306 | it "should use date from the specified zone" do
307 | time = parse("12:13:14", :time, zone: :utc)
308 | expect(time.year).to eq 2009
309 | expect(time.month).to eq 12
310 | expect(time.day).to eq 31
311 | end
312 |
313 | after(:all) do
314 | ENV['TZ'] = @current_tz
315 | Timecop.freeze(2010,1,1,0,0,0)
316 | end
317 | end
318 |
319 | end
320 | end
321 |
322 | describe "_parse" do
323 | context "with no type" do
324 | it "should return date array from date string" do
325 | time_array = parser._parse('2000-02-01')
326 | expect(time_array).to eq [2000,2,1,nil,nil,nil,nil,nil]
327 | end
328 |
329 | it "should return time array from time string" do
330 | time_array = parser._parse('12:13:14', :time)
331 | expect(time_array).to eq [nil,nil,nil,12,13,14,nil,nil]
332 | end
333 |
334 | it "should return datetime array from datetime string" do
335 | time_array = parser._parse('2000-02-01 12:13:14')
336 | expect(time_array).to eq [2000,2,1,12,13,14,nil,nil]
337 | end
338 | end
339 |
340 | context "with type" do
341 | it "should return date array from date string" do
342 | time_array = parser._parse('2000-02-01', :date)
343 | expect(time_array).to eq [2000,2,1,nil,nil,nil,nil,nil]
344 | end
345 |
346 | it "should not return time array from time string for :date type" do
347 | time_array = parser._parse('12:13:14', :date)
348 | expect(time_array).to eq nil
349 | end
350 |
351 | it "should return time array from time string" do
352 | time_array = parser._parse('12:13:14', :time)
353 | expect(time_array).to eq [nil,nil,nil,12,13,14,nil,nil]
354 | end
355 |
356 | it "should not return date array from date string for :time type" do
357 | time_array = parser._parse('2000-02-01', :time)
358 | expect(time_array).to eq nil
359 | end
360 |
361 | it "should return datetime array from datetime string when type is date" do
362 | time_array = parser._parse('2000-02-01 12:13:14', :date)
363 | expect(time_array).to eq [2000,2,1,12,13,14,nil,nil]
364 | end
365 |
366 | it "should return date array from date string when type is datetime" do
367 | time_array = parser._parse('2000-02-01', :datetime)
368 | expect(time_array).to eq [2000,2,1,nil,nil,nil,nil,nil]
369 | end
370 |
371 | it "should not return time array from time string when type is datetime" do
372 | time_array = parser._parse('12:13:14', :datetime)
373 | expect(time_array).to eq nil
374 | end
375 | end
376 |
377 | context "with strict: true" do
378 | it "should return nil from date string when type is datetime" do
379 | time_array = parser._parse('2000-02-01', :datetime, strict: true)
380 | expect(time_array).to be_nil
381 | end
382 |
383 | it "should return nil from datetime string when type is date" do
384 | time_array = parser._parse('2000-02-01 12:13:14', :date, strict: true)
385 | expect(time_array).to be_nil
386 | end
387 |
388 | it "should return nil from datetime string when type is time" do
389 | time_array = parser._parse('2000-02-01 12:13:14', :time, strict: true)
390 | expect(time_array).to be_nil
391 | end
392 |
393 | it "should parse date string when type is date" do
394 | time_array = parser._parse('2000-02-01', :date, strict: true)
395 | expect(time_array).not_to be_nil
396 | end
397 |
398 | it "should parse time string when type is time" do
399 | time_array = parser._parse('12:13:14', :time, strict: true)
400 | expect(time_array).not_to be_nil
401 | end
402 |
403 | it "should parse datetime string when type is datetime" do
404 | time_array = parser._parse('2000-02-01 12:13:14', :datetime, strict: true)
405 | expect(time_array).not_to be_nil
406 | end
407 |
408 | it "should ignore strict parsing if no type specified" do
409 | time_array = parser._parse('2000-02-01', strict: true)
410 | expect(time_array).not_to be_nil
411 | end
412 | end
413 |
414 | context "with :format option" do
415 | it "should return values if string matches specified format" do
416 | time_array = parser._parse('2000-02-01 12:13:14', :datetime, format: 'yyyy-mm-dd hh:nn:ss')
417 | expect(time_array).to eq [2000,2,1,12,13,14,nil,nil]
418 | end
419 |
420 | it "should return nil if string does not match specified format" do
421 | time_array = parser._parse('2000-02-01 12:13', :datetime, format: 'yyyy-mm-dd hh:nn:ss')
422 | expect(time_array).to be_nil
423 | end
424 | end
425 |
426 | context "date with ambiguous year" do
427 | it "should return year in current century if year below threshold" do
428 | time_array = parser._parse('01-02-29', :date)
429 | expect(time_array).to eq [2029,2,1,nil,nil,nil,nil,nil]
430 | end
431 |
432 | it "should return year in last century if year at or above threshold" do
433 | time_array = parser._parse('01-02-30', :date)
434 | expect(time_array).to eq [1930,2,1,nil,nil,nil,nil,nil]
435 | end
436 |
437 | it "should allow custom threshold" do
438 | Timeliness.configuration.ambiguous_year_threshold = 40
439 |
440 | time_array = parser._parse('01-02-39', :date)
441 | expect(time_array).to eq [2039,2,1,nil,nil,nil,nil,nil]
442 | time_array = parser._parse('01-02-40', :date)
443 | expect(time_array).to eq [1940,2,1,nil,nil,nil,nil,nil]
444 | end
445 | end
446 | end
447 |
448 | describe "make_time" do
449 | it "should return time object for valid time array" do
450 | time = parser.make_time([2010,9,8,12,13,14])
451 | expect(time).to eq Time.local(2010,9,8,12,13,14)
452 | end
453 |
454 | it "should return nil for invalid date in array" do
455 | time = parser.make_time([2010,13,8,12,13,14])
456 | expect(time).to be_nil
457 | end
458 |
459 | it "should return nil for invalid time in array" do
460 | time = parser.make_time([2010,9,8,25,13,14])
461 | expect(time).to be_nil
462 | end
463 |
464 | it "should return nil for invalid time in array with timezone" do
465 | time = parser.make_time([2010,9,8,25,13,14,0,1])
466 | expect(time).to be_nil
467 | end
468 |
469 | context "default timezone" do
470 | it "should be used if no zone value" do
471 | Timeliness.configuration.default_timezone = :utc
472 |
473 | time = parser.make_time([2000,6,1,12,0,0])
474 | expect(time.utc_offset).to eq 0
475 | end
476 | end
477 |
478 | context "with zone value" do
479 | context ":utc" do
480 | it "should return time object in utc timezone" do
481 | time = parser.make_time([2000,6,1,12,0,0], :utc)
482 | expect(time.utc_offset).to eq 0
483 | end
484 | end
485 |
486 | context ":local" do
487 | it "should return time object in local system timezone" do
488 | time = parser.make_time([2000,6,1,12,0,0], :local)
489 | expect(time.utc_offset).to eq Time.mktime(2000,6,1,12,0,0).utc_offset
490 | end
491 | end
492 |
493 | context ":current" do
494 | it "should return time object in current timezone" do
495 | Time.zone = 'Adelaide'
496 | time = parser.make_time([2000,6,1,12,0,0], :current)
497 | expect(time.utc_offset).to eq 9.5.hours
498 | end
499 | end
500 |
501 | context "named zone" do
502 | it "should return time object in the timezone" do
503 | time = parser.make_time([2000,6,1,12,0,0], 'London')
504 | expect(time.utc_offset).to eq 1.hour
505 | end
506 | end
507 | end
508 | end
509 |
510 | describe "current_date" do
511 |
512 | context "with no options" do
513 | it 'should return date_for_time_type values with no options' do
514 | dummy_date = Timeliness.configuration.date_for_time_type.call
515 | expect(current_date).to eq [ dummy_date.year, dummy_date.month, dummy_date.day ]
516 | end
517 | end
518 |
519 | context "with :now option" do
520 | it 'should return date array from Time value' do
521 | time = Time.now
522 | date_array = [time.year, time.month, time.day]
523 | expect(current_date(now: time)).to eq date_array
524 | end
525 | end
526 |
527 | context "with :zone option" do
528 | it 'should return date array for utc zone' do
529 | time = Time.now.getutc
530 | date_array = [time.year, time.month, time.day]
531 | expect(current_date(zone: :utc)).to eq date_array
532 | end
533 |
534 | it 'should return date array for local zone' do
535 | time = Time.now
536 | date_array = [time.year, time.month, time.day]
537 | expect(current_date(zone: :local)).to eq date_array
538 | end
539 |
540 | it 'should return date array for current zone' do
541 | Time.zone = 'London'
542 | time = Time.current
543 | date_array = [time.year, time.month, time.day]
544 | expect(current_date(zone: :current)).to eq date_array
545 | end
546 |
547 | it 'should return date array for named zone' do
548 | time = Time.use_zone('London') { Time.current }
549 | date_array = [time.year, time.month, time.day]
550 | expect(current_date(zone: 'London')).to eq date_array
551 | end
552 | end
553 | end
554 |
555 | after(:all) do
556 | Timecop.return
557 | end
558 | end
559 |
--------------------------------------------------------------------------------