├── .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 | --------------------------------------------------------------------------------