diff --git a/.github/workflows/update.yml b/.github/workflows/update.yml index 9e0d465e..540a9967 100644 --- a/.github/workflows/update.yml +++ b/.github/workflows/update.yml @@ -31,7 +31,7 @@ jobs: echo "diff=true" >> $GITHUB_OUTPUT - name: Commit run: | - git commit --message="Update zonetab.h at $(date +%F)" ext/date + git commit --message="Update zonetab at $(date +%F)" ext/date lib/date/zonetab.rb git pull --ff-only origin ${GITHUB_REF#refs/heads/} git push origin ${GITHUB_REF#refs/heads/} env: diff --git a/Rakefile b/Rakefile index 933384c0..06175d09 100644 --- a/Rakefile +++ b/Rakefile @@ -1,30 +1,47 @@ require "bundler/gem_tasks" require "rake/testtask" -require "shellwords" -require "rake/extensiontask" -extask = Rake::ExtensionTask.new("date") do |ext| - ext.name = "date_core" - ext.lib_dir.sub!(%r[(?=/|\z)], "/#{RUBY_VERSION}/#{ext.platform}") -end +if RUBY_VERSION >= "3.3" + # Pure Ruby — no compilation needed + Rake::TestTask.new(:test) do |t| + t.libs << "lib" + t.libs << "test/lib" + t.ruby_opts << "-rhelper" + t.test_files = FileList['test/**/test_*.rb'] + end -Rake::TestTask.new(:test) do |t| - t.libs << extask.lib_dir - t.libs << "test/lib" - t.ruby_opts << "-rhelper" - t.test_files = FileList['test/**/test_*.rb'] -end + task :compile # no-op + +else + # C extension for Ruby < 3.3 + require "shellwords" + require "rake/extensiontask" + + extask = Rake::ExtensionTask.new("date") do |ext| + ext.name = "date_core" + ext.lib_dir.sub!(%r[(?=/|\z)], "/#{RUBY_VERSION}/#{ext.platform}") + end + + Rake::TestTask.new(:test) do |t| + t.libs << extask.lib_dir + t.libs << "test/lib" + t.ruby_opts << "-rhelper" + t.test_files = FileList['test/**/test_*.rb'] + end + + task test: :compile -task compile: "ext/date/zonetab.h" -file "ext/date/zonetab.h" => "ext/date/zonetab.list" do |t| - dir, hdr = File.split(t.name) - make_program_name = - ENV['MAKE'] || ENV['make'] || - RbConfig::CONFIG['configure_args'][/with-make-prog\=\K\w+/] || - (/mswin/ =~ RUBY_PLATFORM ? 'nmake' : 'make') - make_program = Shellwords.split(make_program_name) - sh(*make_program, "-f", "prereq.mk", "top_srcdir=.."+"/.."*dir.count("/"), - hdr, chdir: dir) + task compile: "ext/date/zonetab.h" + file "ext/date/zonetab.h" => "ext/date/zonetab.list" do |t| + dir, hdr = File.split(t.name) + make_program_name = + ENV['MAKE'] || ENV['make'] || + RbConfig::CONFIG['configure_args'][/with-make-prog\=\K\w+/] || + (/mswin/ =~ RUBY_PLATFORM ? 'nmake' : 'make') + make_program = Shellwords.split(make_program_name) + sh(*make_program, "-f", "prereq.mk", "top_srcdir=.."+"/.."*dir.count("/"), + hdr, chdir: dir) + end end task :default => [:compile, :test] diff --git a/date.gemspec b/date.gemspec index cb439bd0..d8da6dd2 100644 --- a/date.gemspec +++ b/date.gemspec @@ -1,29 +1,19 @@ # frozen_string_literal: true -version = File.foreach(File.expand_path("../lib/date.rb", __FILE__)).find do |line| - /^\s*VERSION\s*=\s*["'](.*)["']/ =~ line and break $1 -end +require_relative "lib/date/version" Gem::Specification.new do |s| s.name = "date" - s.version = version + s.version = Date::VERSION s.summary = "The official date library for Ruby." s.description = "The official date library for Ruby." - if Gem::Platform === s.platform and s.platform =~ 'java' or RUBY_ENGINE == 'jruby' - s.platform = 'java' - # No files shipped, no require path, no-op for now on JRuby - else - s.require_path = %w{lib} + s.require_path = %w{lib} - s.files = [ - "README.md", "COPYING", "BSDL", - "lib/date.rb", "ext/date/date_core.c", "ext/date/date_parse.c", "ext/date/date_strftime.c", - "ext/date/date_strptime.c", "ext/date/date_tmx.h", "ext/date/extconf.rb", "ext/date/prereq.mk", - "ext/date/zonetab.h", "ext/date/zonetab.list" - ] - s.extensions = "ext/date/extconf.rb" - end + s.files = Dir["README.md", "COPYING", "BSDL", "lib/**/*.rb", + "ext/date/*.c", "ext/date/*.h", "ext/date/extconf.rb", + "ext/date/prereq.mk", "ext/date/zonetab.list"] + s.extensions = ["ext/date/extconf.rb"] s.required_ruby_version = ">= 2.6.0" diff --git a/ext/date/extconf.rb b/ext/date/extconf.rb index 8a1467df..f00d8771 100644 --- a/ext/date/extconf.rb +++ b/ext/date/extconf.rb @@ -1,13 +1,16 @@ # frozen_string_literal: true require 'mkmf' -config_string("strict_warnflags") {|w| $warnflags += " #{w}"} - -append_cflags("-Wno-compound-token-split-by-macro") if RUBY_VERSION < "2.7." -have_func("rb_category_warn") -with_werror("", {:werror => true}) do |opt, | - have_var("timezone", "time.h", opt) - have_var("altzone", "time.h", opt) +if RUBY_VERSION >= "3.3" + # Pure Ruby implementation; skip C extension build + File.write("Makefile", dummy_makefile($srcdir).join("")) +else + config_string("strict_warnflags") {|w| $warnflags += " #{w}"} + append_cflags("-Wno-compound-token-split-by-macro") if RUBY_VERSION < "2.7." + have_func("rb_category_warn") + with_werror("", {:werror => true}) do |opt, | + have_var("timezone", "time.h", opt) + have_var("altzone", "time.h", opt) + end + create_makefile('date_core') end - -create_makefile('date_core') diff --git a/ext/date/generate-zonetab-rb b/ext/date/generate-zonetab-rb new file mode 100755 index 00000000..64b72c7e --- /dev/null +++ b/ext/date/generate-zonetab-rb @@ -0,0 +1,59 @@ +# -*- mode: ruby -*- +# Generate lib/date/zonetab.rb from ext/date/zonetab.list +# +# Usage: ruby -C ext/date generate-zonetab-rb +# +# This script reads zonetab.list (the same source used to generate zonetab.h +# via gperf) and produces the equivalent Ruby hash table. + +list_path = File.join(__dir__, 'zonetab.list') +output_path = File.join(__dir__, '..', '..', 'lib', 'date', 'zonetab.rb') + +entries = {} +in_entries = false + +File.foreach(list_path) do |line| + line.chomp! + if line == '%%' + if in_entries + break + else + in_entries = true + next + end + end + next unless in_entries + + abbr, offset_expr = line.split(',', 2) + next unless offset_expr + + abbr.strip! + offset_expr.strip! + + # Evaluate offset expression (e.g., "0*3600", "-5*3600", "-(1*3600+1800)", "16200") + offset = eval(offset_expr) + + entries[abbr] = offset +end + +sorted = entries.sort_by { |k, _| k } + +max_key_len = sorted.map { |k, _| k.length }.max + +File.open(output_path, 'w') do |f| + f.puts '# frozen_string_literal: true' + f.puts + f.puts '# Timezone name => UTC offset (seconds) mapping table.' + f.puts '# Auto-generated from ext/date/zonetab.list by ext/date/generate-zonetab-rb.' + f.puts '# Do not edit manually.' + f.puts 'class Date' + f.puts ' ZONE_TABLE = {' + sorted.each do |abbr, offset| + key = abbr.include?(' ') ? %Q("#{abbr}") : %Q("#{abbr}") + f.puts " %-#{max_key_len + 3}s => %d," % [key, offset] + end + f.puts ' }.freeze' + f.puts 'end' +end + +puts "Generated #{output_path} with #{entries.size} entries." diff --git a/ext/date/prereq.mk b/ext/date/prereq.mk index b5d271a3..7f8e0bbf 100644 --- a/ext/date/prereq.mk +++ b/ext/date/prereq.mk @@ -10,6 +10,7 @@ zonetab.h: zonetab.list .PHONY: update-zonetab update-zonetab: $(RUBY) -C $(srcdir) update-abbr + $(RUBY) -C $(srcdir) generate-zonetab-rb .PHONY: update-nothing update-nothing: diff --git a/lib/date.rb b/lib/date.rb index 0cb76301..d5869a61 100644 --- a/lib/date.rb +++ b/lib/date.rb @@ -1,11 +1,24 @@ # frozen_string_literal: true # date.rb: Written by Tadayoshi Funaba 1998-2011 -require 'date_core' +require 'timeout' +require 'strscan' -class Date - VERSION = "3.5.1" # :nodoc: +if RUBY_VERSION >= "3.3" + require_relative "date/version" + require_relative "date/constants" + require_relative "date/shared" + require_relative "date/core" + require_relative "date/strftime" + require_relative "date/parse" + require_relative "date/strptime" + require_relative "date/time" + require_relative "date/datetime" +else + require 'date_core' +end +class Date # call-seq: # infinite? -> false # @@ -64,7 +77,5 @@ def to_f -Float::INFINITY end end - end - end diff --git a/lib/date/constants.rb b/lib/date/constants.rb new file mode 100644 index 00000000..707018ed --- /dev/null +++ b/lib/date/constants.rb @@ -0,0 +1,364 @@ +# encoding: US-ASCII +# frozen_string_literal: true + +# Constants +class Date + MONTHNAMES = [nil, 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December'].freeze + ABBR_MONTHNAMES = [nil, 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'].freeze + DAYNAMES = %w[Sunday Monday Tuesday Wednesday Thursday Friday Saturday].freeze + ABBR_DAYNAMES = %w[Sun Mon Tue Wed Thu Fri Sat].freeze + + ITALY = 2299161 # 1582-10-15 + ENGLAND = 2361222 # 1752-09-14 + JULIAN = Float::INFINITY + GREGORIAN = -Float::INFINITY + + DEFAULT_SG = ITALY + private_constant :DEFAULT_SG + + # 3-byte integer key lookup tables for O(1) abbreviated name matching. + # Key = (byte0_lower << 16) | (byte1_lower << 8) | byte2_lower + # Value = [index, full_name_length] + ABBR_DAY_3KEY = ABBR_DAYNAMES.each_with_index.to_h { |n, i| + b = n.downcase.bytes + [(b[0] << 16) | (b[1] << 8) | b[2], [i, DAYNAMES[i].length].freeze] + }.freeze + ABBR_MONTH_3KEY = ABBR_MONTHNAMES.each_with_index.each_with_object({}) { |(n, i), h| + next if n.nil? + b = n.downcase.bytes + h[(b[0] << 16) | (b[1] << 8) | b[2]] = [i, MONTHNAMES[i].length].freeze + }.freeze + private_constant :ABBR_DAY_3KEY, :ABBR_MONTH_3KEY + + # Case-insensitive abbreviated month name -> month number (1-12) + ABBR_MONTH_NUM = ABBR_MONTHNAMES.each_with_index.each_with_object({}) { |(n, i), h| + next if n.nil? + h[n.downcase] = i + }.freeze + + # Case-insensitive abbreviated day name -> wday number (0-6) + ABBR_DAY_NUM = ABBR_DAYNAMES.each_with_index.to_h { |n, i| [n.downcase, i] }.freeze + private_constant :ABBR_MONTH_NUM, :ABBR_DAY_NUM + + # Lowercase full name strings for StringScanner-based head matching + DAY_LOWER_STRS = DAYNAMES.map { |n| n.downcase.freeze }.freeze + MONTH_LOWER_STRS = MONTHNAMES.map { |n| n&.downcase&.freeze }.freeze + private_constant :DAY_LOWER_STRS, :MONTH_LOWER_STRS + + + JULIAN_EPOCH_DATE = '-4712-01-01'.freeze + JULIAN_EPOCH_DATETIME = '-4712-01-01T00:00:00+00:00'.freeze + JULIAN_EPOCH_DATETIME_RFC2822 = 'Mon, 1 Jan -4712 00:00:00 +0000'.freeze + JULIAN_EPOCH_DATETIME_HTTPDATE = 'Mon, 01 Jan -4712 00:00:00 GMT'.freeze + private_constant :JULIAN_EPOCH_DATE, :JULIAN_EPOCH_DATETIME, + :JULIAN_EPOCH_DATETIME_RFC2822, :JULIAN_EPOCH_DATETIME_HTTPDATE + + # === Calendar computation (from core.rb) === + + # Days in month for Gregorian calendar. + DAYS_IN_MONTH_GREGORIAN = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31].freeze + private_constant :DAYS_IN_MONTH_GREGORIAN + + # Precomputed month offset for Julian Day computation: + # GJD_MONTH_OFFSET[m] == (306001 * (gm + 1)) / 10000 + # where gm = m + 12 for m <= 2, else gm = m. + # Used to avoid an integer multiply in the hot path of civil_to_jd. + GJD_MONTH_OFFSET = [nil, 428, 459, 122, 153, 183, 214, 244, 275, 306, 336, 367, 397].freeze + private_constant :GJD_MONTH_OFFSET + + STRFTIME_DATE_DEFAULT_FMT = '%F'.encode(Encoding::US_ASCII) + private_constant :STRFTIME_DATE_DEFAULT_FMT + + ASCTIME_DAYS = %w[Sun Mon Tue Wed Thu Fri Sat].freeze + ASCTIME_MONS = [nil, 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'].freeze + RFC2822_DAYS = ASCTIME_DAYS + private_constant :ASCTIME_DAYS, :ASCTIME_MONS, :RFC2822_DAYS + + # Pre-computed " Mon " strings for rfc2822/httpdate: " Jan ", " Feb ", ... + RFC_MON_SPACE = ASCTIME_MONS.map { |m| m ? " #{m} ".freeze : nil }.freeze + private_constant :RFC_MON_SPACE + + ERA_TABLE = [ + [2458605, 'R', 2018], # Reiwa: 2019-05-01 + [2447535, 'H', 1988], # Heisei: 1989-01-08 + [2424875, 'S', 1925], # Showa: 1926-12-25 + [2419614, 'T', 1911], # Taisho: 1912-07-30 + [2405160, 'M', 1867], # Meiji: 1873-01-01 + ].freeze + private_constant :ERA_TABLE + + # Pre-built "-MM-DD" suffixes for all valid month/day combinations. + # Indexed by [month][day]. Avoids per-call format() for the month/day portion. + MONTH_DAY_SUFFIX = Array.new(13) { |m| + Array.new(32) { |d| + next nil if m == 0 || d == 0 + format('-%02d-%02d', m, d).freeze + }.freeze + }.freeze + private_constant :MONTH_DAY_SUFFIX + + # === String formatting (from strftime.rb) === + + DEFAULT_STRFTIME_FMT = '%F' + private_constant :DEFAULT_STRFTIME_FMT + + YMD_FMT = '%Y-%m-%d' + private_constant :YMD_FMT + + # Locale-independent month/day name tables (same as C ext) + STRFTIME_MONTHS_FULL = MONTHNAMES.freeze + STRFTIME_MONTHS_ABBR = ABBR_MONTHNAMES.freeze + STRFTIME_DAYS_FULL = DAYNAMES.freeze + STRFTIME_DAYS_ABBR = ABBR_DAYNAMES.freeze + private_constant :STRFTIME_MONTHS_FULL, :STRFTIME_MONTHS_ABBR, + :STRFTIME_DAYS_FULL, :STRFTIME_DAYS_ABBR + + # Pre-computed "Sun Jan " prefix table for %c / asctime [wday][month] + ASCTIME_PREFIX = Array.new(7) { |w| + Array.new(13) { |m| + m == 0 ? nil : "#{STRFTIME_DAYS_ABBR[w]} #{STRFTIME_MONTHS_ABBR[m]} ".freeze + }.freeze + }.freeze + private_constant :ASCTIME_PREFIX + + # Pre-computed "Saturday, " prefix table for %A format [wday] + DAY_FULL_COMMA = STRFTIME_DAYS_FULL.map { |d| "#{d}, ".freeze }.freeze + # Pre-computed "March " prefix table for %B format [month] + MONTH_FULL_SPACE = STRFTIME_MONTHS_FULL.map { |m| m ? "#{m} ".freeze : nil }.freeze + private_constant :DAY_FULL_COMMA, :MONTH_FULL_SPACE + + # Bitmask flag constants for strftime parsing + FL_LEFT = 0x01 # '-' flag + FL_SPACE = 0x02 # '_' flag + FL_ZERO = 0x04 # '0' flag + FL_UPPER = 0x08 # '^' flag + FL_CHCASE = 0x10 # '#' flag + private_constant :FL_LEFT, :FL_SPACE, :FL_ZERO, :FL_UPPER, :FL_CHCASE + + # Pre-computed 2-digit zero-padded strings for 0..99 + PAD2 = (0..99).map { |n| format('%02d', n).freeze }.freeze + private_constant :PAD2 + + # Map composite spec bytes to their expansion strings + STRFTIME_COMPOSITE_BYTE = { + 99 => '%a %b %e %H:%M:%S %Y', # 'c' + 68 => '%m/%d/%y', # 'D' + 70 => '%Y-%m-%d', # 'F' + 110 => "\n", # 'n' + 114 => '%I:%M:%S %p', # 'r' + 82 => '%H:%M', # 'R' + 116 => "\t", # 't' + 84 => '%H:%M:%S', # 'T' + 118 => '%e-%^b-%4Y', # 'v' + 88 => '%H:%M:%S', # 'X' + 120 => '%m/%d/%y', # 'x' + }.freeze + private_constant :STRFTIME_COMPOSITE_BYTE + + # Valid specs for %E locale modifier (as byte values) + # c=99, C=67, x=120, X=88, y=121, Y=89 + STRFTIME_E_VALID_BYTES = [99, 67, 120, 88, 121, 89].freeze + # Valid specs for %O locale modifier (as byte values) + # d=100, e=101, H=72, k=107, I=73, l=108, m=109, M=77, S=83, u=117, U=85, V=86, w=119, W=87, y=121 + STRFTIME_O_VALID_BYTES = [100, 101, 72, 107, 73, 108, 109, 77, 83, 117, 85, 86, 119, 87, 121].freeze + private_constant :STRFTIME_E_VALID_BYTES, :STRFTIME_O_VALID_BYTES + + # Maximum allowed format width to prevent unreasonable memory allocation + STRFTIME_MAX_WIDTH = 65535 + # Maximum length for a single formatted field (matches C's STRFTIME_MAX_COPY_LEN) + STRFTIME_MAX_COPY_LEN = 1024 + private_constant :STRFTIME_MAX_WIDTH, :STRFTIME_MAX_COPY_LEN + + # === Parse regex patterns (from parse.rb) === + + RFC3339_RE = /\A\s*(-?\d{4})-(\d{2})-(\d{2})[Tt ](\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(Z|[-+]\d{2}:\d{2})\s*\z/i + private_constant :RFC3339_RE + + HTTPDATE_TYPE1_RE = /\A\s*(sun|mon|tue|wed|thu|fri|sat)\s*,\s+(\d{2})\s+(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\s+(-?\d{4})\s+(\d{2}):(\d{2}):(\d{2})\s+(gmt)\s*\z/i + HTTPDATE_TYPE2_RE = /\A\s*(sunday|monday|tuesday|wednesday|thursday|friday|saturday)\s*,\s+(\d{2})\s*-\s*(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\s*-\s*(\d{2})\s+(\d{2}):(\d{2}):(\d{2})\s+(gmt)\s*\z/i + HTTPDATE_TYPE3_RE = /\A\s*(sun|mon|tue|wed|thu|fri|sat)\s+(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\s+(\d{1,2})\s+(\d{2}):(\d{2}):(\d{2})\s+(\d{4})\s*\z/i + # Fast path: simplified Type 1 with generic [a-zA-Z] instead of alternation + FAST_HTTPDATE_TYPE1_RE = /\A\s*([a-zA-Z]{3}),\s+(\d{2})\s+([a-zA-Z]{3})\s+(\d{4})\s+(\d{2}):(\d{2}):(\d{2})\s+(GMT)\s*\z/i + private_constant :HTTPDATE_TYPE1_RE, :HTTPDATE_TYPE2_RE, :HTTPDATE_TYPE3_RE, + :FAST_HTTPDATE_TYPE1_RE + + # Wday lookup from abbreviated day name (3-char lowercase key) + HTTPDATE_WDAY = {'sun'=>0,'mon'=>1,'tue'=>2,'wed'=>3,'thu'=>4,'fri'=>5,'sat'=>6}.freeze + HTTPDATE_FULL_WDAY = {'sunday'=>0,'monday'=>1,'tuesday'=>2,'wednesday'=>3,'thursday'=>4,'friday'=>5,'saturday'=>6}.freeze + private_constant :HTTPDATE_WDAY, :HTTPDATE_FULL_WDAY + + RFC2822_RE = /\A\s*(?:(sun|mon|tue|wed|thu|fri|sat)\s*,\s+)?(\d{1,2})\s+(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\s+(-?\d{2,})\s+(\d{2}):(\d{2})(?::(\d{2}))?\s*([-+]\d{4}|ut|gmt|e[sd]t|c[sd]t|m[sd]t|p[sd]t|[a-ik-z])\s*\z/i + private_constant :RFC2822_RE + + XMLSCHEMA_DATETIME_RE = /\A\s*(-?\d{4,})(?:-(\d{2})(?:-(\d{2}))?)?(?:t(\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?)?(z|[-+]\d{2}:\d{2})?\s*\z/i + XMLSCHEMA_TIME_RE = /\A\s*(\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(z|[-+]\d{2}:\d{2})?\s*\z/i + XMLSCHEMA_TRUNC_RE = /\A\s*(?:--(\d{2})(?:-(\d{2}))?|---(\d{2}))(z|[-+]\d{2}:\d{2})?\s*\z/i + private_constant :XMLSCHEMA_DATETIME_RE, :XMLSCHEMA_TIME_RE, :XMLSCHEMA_TRUNC_RE + + ISO8601_EXT_DATETIME_RE = %r{\A\s* + (?: + ([-+]?\d{2,}|-)-(\d{2})?(?:-(\d{2}))? | # year-mon-mday or --mon-mday or ---mday + ([-+]?\d{2,})?-(\d{3}) | # year-yday + (\d{4}|\d{2})?-w(\d{2})(?:-(\d))? | # cwyear-wNN-D + -w-(\d) # -w-D + ) + (?:t + (\d{2}):(\d{2})(?::(\d{2})(?:[,.](\d+))?)? + (z|[-+]\d{2}(?::?\d{2})?)? + )? + \s*\z}xi + + ISO8601_BAS_DATETIME_RE = %r{\A\s* + (?: + ([-+]?(?:\d{4}|\d{2})|--)(\d{2}|-)(\d{2}) | # yyyymmdd / --mmdd / ----dd + ([-+]?(?:\d{4}|\d{2}))(\d{3}) | # yyyyddd + -(\d{3}) | # -ddd + (\d{4}|\d{2})w(\d{2})(\d) | # yyyywwwd + -w(\d{2})(\d) | # -wNN-D + -w-(\d) # -w-D + ) + (?:t? + (\d{2})(\d{2})(?:(\d{2})(?:[,.](\d+))?)? + (z|[-+]\d{2}(\d{2})?)? + )? + \s*\z}xi + + ISO8601_EXT_TIME_RE = /\A\s*(\d{2}):(\d{2})(?::(\d{2})(?:[,.](\d+))?(z|[-+]\d{2}(?::?\d{2})?)?)?\s*\z/i + ISO8601_BAS_TIME_RE = /\A\s*(\d{2})(\d{2})(?:(\d{2})(?:[,.](\d+))?(z|[-+]\d{2}(\d{2})?)?)?\s*\z/i + private_constant :ISO8601_EXT_DATETIME_RE, :ISO8601_BAS_DATETIME_RE, + :ISO8601_EXT_TIME_RE, :ISO8601_BAS_TIME_RE + + JISX0301_ERA = { 'm' => 1867, 't' => 1911, 's' => 1925, 'h' => 1988, 'r' => 2018 }.freeze + JISX0301_RE = /\A\s*([mtshr])?(\d{2})\.(\d{2})\.(\d{2})(?:t(?:(\d{2}):(\d{2})(?::(\d{2})(?:[,.](\d*))?)?(z|[-+]\d{2}(?::?\d{2})?)?)?)?\s*\z/i + private_constant :JISX0301_ERA, :JISX0301_RE + + # Character class flags + HAVE_ALPHA = 1 + HAVE_DIGIT = 2 + HAVE_DASH = 4 + HAVE_DOT = 8 + HAVE_SLASH = 16 + HAVE_COLON = 32 + private_constant :HAVE_ALPHA, :HAVE_DIGIT, :HAVE_DASH, :HAVE_DOT, :HAVE_SLASH, :HAVE_COLON + + PARSE_DAYS_RE = /\b(sun|mon|tue|wed|thu|fri|sat)[^-\/\d\s]*/i + PARSE_MON_RE = /\b(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\S*/i + PARSE_MDAY_RE = /(?= 1 && month <= 12 + if day >= 1 && day <= 28 + return new_from_jd(civil_to_jd(year, month, day, start), start) + elsif day >= -31 + dim = if month == 2 + if start == Float::INFINITY + year % 4 == 0 ? 29 : 28 + else + (year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)) ? 29 : 28 + end + else + DAYS_IN_MONTH_GREGORIAN[month] + end + d = day < 0 ? day + dim + 1 : day + if d >= 1 && d <= dim + return new_from_jd(civil_to_jd(year, month, d, start), start) + end + end + end + civil_fallback(year, month, day, start) + end + + def new(year = -4712, month = 1, day = 1, start = DEFAULT_SG) + civil(year, month, day, start) + end + + # call-seq: + # Date.valid_civil?(year, month, mday, start = Date::ITALY) -> true or false + # + # Returns +true+ if the arguments define a valid ordinal date, + # +false+ otherwise: + # + # Date.valid_date?(2001, 2, 3) # => true + # Date.valid_date?(2001, 2, 29) # => false + # Date.valid_date?(2001, 2, -1) # => true + # + # See argument {start}[rdoc-ref:language/calendars.rdoc@Argument+start]. + # + # Related: Date.jd, Date.new. + def valid_civil?(year, month, day, start = DEFAULT_SG) + return false unless year.is_a?(Numeric) && month.is_a?(Numeric) && day.is_a?(Numeric) + !!internal_valid_civil?(year, month, day, start) + end + alias_method :valid_date?, :valid_civil? + + # call-seq: + # Date.jd(jd = 0, start = Date::ITALY) -> date + # + # Returns a new \Date object formed from the arguments: + # + # Date.jd(2451944).to_s # => "2001-02-03" + # Date.jd(2451945).to_s # => "2001-02-04" + # Date.jd(0).to_s # => "-4712-01-01" + # + # The returned date is: + # + # - Gregorian, if the argument is greater than or equal to +start+: + # + # Date::ITALY # => 2299161 + # Date.jd(Date::ITALY).gregorian? # => true + # Date.jd(Date::ITALY + 1).gregorian? # => true + # + # - Julian, otherwise + # + # Date.jd(Date::ITALY - 1).julian? # => true + # + # See argument {start}[rdoc-ref:language/calendars.rdoc@Argument+start]. + # + # Related: Date.new. + def jd(jd = 0, start = DEFAULT_SG) + jd = Integer(jd) + obj = allocate + obj.__send__(:init_from_jd, jd, start) + obj + end + + # call-seq: + # Date.valid_jd?(jd, start = Date::ITALY) -> true + # + # Implemented for compatibility; + # returns +true+ unless +jd+ is invalid (i.e., not a Numeric). + # + # Date.valid_jd?(2451944) # => true + # + # See argument {start}[rdoc-ref:language/calendars.rdoc@Argument+start]. + # + # Related: Date.jd. + def valid_jd?(jd, _start = DEFAULT_SG) + jd.is_a?(Numeric) + end + + # call-seq: + # Date.gregorian_leap?(year) -> true or false + # + # Returns +true+ if the given year is a leap year + # in the {proleptic Gregorian calendar}[https://en.wikipedia.org/wiki/Proleptic_Gregorian_calendar], +false+ otherwise: + # + # Date.gregorian_leap?(2000) # => true + # Date.gregorian_leap?(2001) # => false + # + # Related: Date.julian_leap?. + def gregorian_leap?(year) + raise TypeError, "expected numeric" unless year.is_a?(Numeric) + internal_gregorian_leap?(year) + end + alias_method :leap?, :gregorian_leap? + + # call-seq: + # Date.julian_leap?(year) -> true or false + # + # Returns +true+ if the given year is a leap year + # in the {proleptic Julian calendar}[https://en.wikipedia.org/wiki/Proleptic_Julian_calendar], +false+ otherwise: + # + # Date.julian_leap?(1900) # => true + # Date.julian_leap?(1901) # => false + # + # Related: Date.gregorian_leap?. + def julian_leap?(year) + raise TypeError, "expected numeric" unless year.is_a?(Numeric) + internal_julian_leap?(year) + end + + # call-seq: + # Date.ordinal(year = -4712, yday = 1, start = Date::ITALY) -> date + # + # Returns a new \Date object formed fom the arguments. + # + # With no arguments, returns the date for January 1, -4712: + # + # Date.ordinal.to_s # => "-4712-01-01" + # + # With argument +year+, returns the date for January 1 of that year: + # + # Date.ordinal(2001).to_s # => "2001-01-01" + # Date.ordinal(-2001).to_s # => "-2001-01-01" + # + # With positive argument +yday+ == +n+, + # returns the date for the +nth+ day of the given year: + # + # Date.ordinal(2001, 14).to_s # => "2001-01-14" + # + # With negative argument +yday+, counts backward from the end of the year: + # + # Date.ordinal(2001, -14).to_s # => "2001-12-18" + # + # Raises an exception if +yday+ is zero or out of range. + # + # See argument {start}[rdoc-ref:language/calendars.rdoc@Argument+start]. + # + # Related: Date.jd, Date.new. + def ordinal(year = -4712, yday = 1, start = DEFAULT_SG) + if Integer === year && Integer === yday && yday >= 1 && yday <= 365 + jd1 = civil_to_jd(year, 1, 1, start) + return new_from_jd(jd1 + yday - 1, start) + end + year = Integer(year) + yday = Integer(yday) + jd = internal_valid_ordinal?(year, yday, start) + raise Date::Error, "invalid date" unless jd + new_from_jd(jd, start) + end + + # call-seq: + # Date.valid_ordinal?(year, yday, start = Date::ITALY) -> true or false + # + # Returns +true+ if the arguments define a valid ordinal date, + # +false+ otherwise: + # + # Date.valid_ordinal?(2001, 34) # => true + # Date.valid_ordinal?(2001, 366) # => false + # + # See argument {start}[rdoc-ref:language/calendars.rdoc@Argument+start]. + # + # Related: Date.jd, Date.ordinal. + def valid_ordinal?(year, day, start = DEFAULT_SG) + return false unless year.is_a?(Numeric) && day.is_a?(Numeric) + !!internal_valid_ordinal?(year, day, start) + end + + # call-seq: + # Date.commercial(cwyear = -4712, cweek = 1, cwday = 1, start = Date::ITALY) -> date + # + # Returns a new \Date object constructed from the arguments. + # + # Argument +cwyear+ gives the year, and should be an integer. + # + # Argument +cweek+ gives the index of the week within the year, + # and should be in range (1..53) or (-53..-1); + # in some years, 53 or -53 will be out-of-range; + # if negative, counts backward from the end of the year: + # + # Date.commercial(2022, 1, 1).to_s # => "2022-01-03" + # Date.commercial(2022, 52, 1).to_s # => "2022-12-26" + # + # Argument +cwday+ gives the indes of the weekday within the week, + # and should be in range (1..7) or (-7..-1); + # 1 or -7 is Monday; + # if negative, counts backward from the end of the week: + # + # Date.commercial(2022, 1, 1).to_s # => "2022-01-03" + # Date.commercial(2022, 1, -7).to_s # => "2022-01-03" + # + # When +cweek+ is 1: + # + # - If January 1 is a Friday, Saturday, or Sunday, + # the first week begins in the week after: + # + # Date::ABBR_DAYNAMES[Date.new(2023, 1, 1).wday] # => "Sun" + # Date.commercial(2023, 1, 1).to_s # => "2023-01-02" + # Date.commercial(2023, 1, 7).to_s # => "2023-01-08" + # + # - Otherwise, the first week is the week of January 1, + # which may mean some of the days fall on the year before: + # + # Date::ABBR_DAYNAMES[Date.new(2020, 1, 1).wday] # => "Wed" + # Date.commercial(2020, 1, 1).to_s # => "2019-12-30" + # Date.commercial(2020, 1, 7).to_s # => "2020-01-05" + # + # See argument {start}[rdoc-ref:language/calendars.rdoc@Argument+start]. + # + # Related: Date.jd, Date.new, Date.ordinal. + def commercial(cwyear = -4712, cweek = 1, cwday = 1, start = DEFAULT_SG) + if Integer === cwyear && Integer === cweek && Integer === cwday && + cweek >= 1 && cweek <= 52 && cwday >= 1 && cwday <= 7 + # ISO 8601: every year has at least 52 weeks, so weeks 1-52 are always valid. + jd = commercial_to_jd(cwyear, cweek, cwday, start) + return new_from_jd(jd, start) + end + cwyear = Integer(cwyear) + cweek = Integer(cweek) + cwday = Integer(cwday) + jd = internal_valid_commercial?(cwyear, cweek, cwday, start) + raise Date::Error, "invalid date" unless jd + new_from_jd(jd, start) + end + + # call-seq: + # Date.valid_commercial?(cwyear, cweek, cwday, start = Date::ITALY) -> true or false + # + # Returns +true+ if the arguments define a valid commercial date, + # +false+ otherwise: + # + # Date.valid_commercial?(2001, 5, 6) # => true + # Date.valid_commercial?(2001, 5, 8) # => false + # + # See Date.commercial. + # + # See argument {start}[rdoc-ref:language/calendars.rdoc@Argument+start]. + # + # Related: Date.jd, Date.commercial. + def valid_commercial?(year, week, day, start = DEFAULT_SG) + return false unless year.is_a?(Numeric) && week.is_a?(Numeric) && day.is_a?(Numeric) + !!internal_valid_commercial?(year, week, day, start) + end + + # call-seq: + # Date.weeknum(year, week, wday, wstart = 0, start = Date::ITALY) -> date + def weeknum(year = -4712, week = 0, wday = 1, wstart = 0, start = DEFAULT_SG) + year = Integer(year) + week = Integer(week) + wday = Integer(wday) + wstart = Integer(wstart) + # Validate wday range + raise Date::Error, "invalid date" unless wday >= 0 && wday <= 6 + # Validate week: reconstruct and check round-trip + jd = weeknum_to_jd(year, week, wday, wstart, start) + # Verify the resulting date is in the same year (week must be valid) + y2, = jd_to_civil(jd, start) + raise Date::Error, "invalid date" if y2 != year + new_from_jd(jd, start) + end + + # call-seq: + # Date.nth_kday(year, month, n, k, start = Date::ITALY) -> date + def nth_kday(year = -4712, month = 1, n = 1, k = 1, start = DEFAULT_SG) + year = Integer(year) + month = Integer(month) + n = Integer(n) + k = Integer(k) + raise Date::Error, "invalid date" unless month >= 1 && month <= 12 + raise Date::Error, "invalid date" unless k >= 0 && k <= 6 + raise Date::Error, "invalid date" if n == 0 + jd = nth_kday_to_jd(year, month, n, k, start) + # Verify the result is in the same month + y2, m2, = jd_to_civil(jd, start) + raise Date::Error, "invalid date" if y2 != year || m2 != month + new_from_jd(jd, start) + end + + # call-seq: + # Date.today(start = Date::ITALY) -> date + # + # Returns a new \Date object constructed from the present date: + # + # Date.today.to_s # => "2022-07-06" + # + # See argument {start}[rdoc-ref:language/calendars.rdoc@Argument+start]. + # + def today(start = DEFAULT_SG) + t = Time.now + jd = civil_to_jd(t.year, t.mon, t.mday, start) + new_from_jd(jd, start) + end + + # :nodoc: + def _load(s) + a = Marshal.load(s) + obj = allocate + obj.marshal_load(a) + obj + end + + # :nodoc: + def new!(ajd = 0, of = 0, sg = DEFAULT_SG) + # ajd is Astronomical Julian Day (may be Rational) + # Convert to integer JD and day fraction (same as C's old_to_new) + raw_jd = ajd + 0.5r + jd = raw_jd.floor + df = raw_jd - jd + obj = allocate + obj.__send__(:init_from_jd, jd, sg, df == 0 ? nil : df) + obj + end + + private + + def civil_fallback(year, month, day, start) + year = Integer(year) + month = Integer(month) + day = Integer(day) + jd = internal_valid_civil?(year, month, day, start) + raise Date::Error, "invalid date" unless jd + new_from_jd(jd, start) + end + + # --------------------------------------------------------------------------- + # Internal calendar arithmetic (pure Ruby, no C dependency) + # --------------------------------------------------------------------------- + + # Floor division that works correctly for negative numbers. + def idiv(a, b) + a.div(b) + end + + # Gregorian leap year? + def internal_gregorian_leap?(y) + (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 + end + + # Julian leap year? + def internal_julian_leap?(y) + y % 4 == 0 + end + + def days_in_month_gregorian(y, m) + if m == 2 && internal_gregorian_leap?(y) + 29 + else + DAYS_IN_MONTH_GREGORIAN[m] + end + end + + # Days in month for Julian calendar. + def days_in_month_julian(y, m) + if m == 2 && internal_julian_leap?(y) + 29 + else + DAYS_IN_MONTH_GREGORIAN[m] + end + end + + # Gregorian civil (year, month, day) -> Julian Day Number + def gregorian_to_jd(y, m, d) + if m <= 2 + y -= 1 + m += 12 + end + a = y / 100 + b = 2 - a + a / 4 + (1461 * (y + 4716)) / 4 + (306001 * (m + 1)) / 10000 + d + b - 1524 + end + + # Julian civil (year, month, day) -> Julian Day Number + def julian_to_jd(y, m, d) + if m <= 2 + y -= 1 + m += 12 + end + (1461 * (y + 4716)) / 4 + (306001 * (m + 1)) / 10000 + d - 1524 + end + + # Civil (year, month, day) -> JD, respecting start (cutover). + # Uses a unified formula: gjd >= sg -> Gregorian JD, else Julian JD. + # Works for all sg values: Float::INFINITY (always Julian), + # -Float::INFINITY (always Gregorian), or integer cutover JD. + def civil_to_jd(y, m, d, sg) + offset = GJD_MONTH_OFFSET[m] + y -= 1 if m <= 2 + gjd_base = (1461 * (y + 4716)) / 4 + offset + d + a = y / 100 + gjd = gjd_base - 1524 + 2 - a + a / 4 + gjd >= sg ? gjd : gjd_base - 1524 + end + + # Gregorian JD -> (year, month, day) + def jd_to_gregorian(jd) + a = jd + 32044 + b = idiv(4 * a + 3, 146097) + c = a - idiv(146097 * b, 4) + d = idiv(4 * c + 3, 1461) + e = c - idiv(1461 * d, 4) + m = idiv(5 * e + 2, 153) + day = e - idiv(153 * m + 2, 5) + 1 + mon = m + 3 - 12 * idiv(m, 10) + year = 100 * b + d - 4800 + idiv(m, 10) + [year, mon, day] + end + + # Julian JD -> (year, month, day) + def jd_to_julian(jd) + c = jd + 32082 + d = idiv(4 * c + 3, 1461) + e = c - idiv(1461 * d, 4) + m = idiv(5 * e + 2, 153) + day = e - idiv(153 * m + 2, 5) + 1 + mon = m + 3 - 12 * idiv(m, 10) + year = d - 4800 + idiv(m, 10) + [year, mon, day] + end + + # JD -> (year, month, day), respecting start (cutover). + def jd_to_civil(jd, sg) + if sg == Float::INFINITY + jd_to_julian(jd) + elsif sg == -Float::INFINITY || jd >= sg + jd_to_gregorian(jd) + else + jd_to_julian(jd) + end + end + + # Ordinal (year, day-of-year) -> JD + def ordinal_to_jd(y, d, sg) + civil_to_jd(y, 1, 1, sg) + d - 1 + end + + # JD -> (year, day-of-year) + def jd_to_ordinal(jd, sg) + y, = jd_to_civil(jd, sg) + jd_jan1 = civil_to_jd(y, 1, 1, sg) + [y, jd - jd_jan1 + 1] + end + + # Commercial (ISO week: cwyear, cweek, cwday) -> JD + def commercial_to_jd(y, w, d, sg = -Float::INFINITY) + # Jan 4 is always in week 1 (ISO 8601) + jd_jan4 = civil_to_jd(y, 1, 4, sg) + # Monday of week 1 + wday_jan4 = (jd_jan4 + 1) % 7 # 0=Sun + iso_wday_jan4 = wday_jan4 == 0 ? 7 : wday_jan4 + mon_wk1 = jd_jan4 - (iso_wday_jan4 - 1) + mon_wk1 + (w - 1) * 7 + (d - 1) + end + + # JD -> (cwyear, cweek, cwday) + def jd_to_commercial(jd, sg = -Float::INFINITY) + wday = (jd + 1) % 7 # 0=Sun + cwday = wday == 0 ? 7 : wday # 1=Mon..7=Sun + # Thursday of the same ISO week + thursday = jd + (4 - cwday) + y, = thursday >= sg ? jd_to_gregorian(thursday) : jd_to_julian(thursday) + jd_jan4 = civil_to_jd(y, 1, 4, sg) + wday_jan4 = (jd_jan4 + 1) % 7 + iso_wday_jan4 = wday_jan4 == 0 ? 7 : wday_jan4 + mon_wk1 = jd_jan4 - (iso_wday_jan4 - 1) + cweek = (jd - mon_wk1) / 7 + 1 + [y, cweek, cwday] + end + + # Weeknum (year, week, wday, week_start, sg) -> JD + # week_start: 0=Sun-based (%U), 1=Mon-based (%W) + def weeknum_to_jd(y, w, d, ws, sg = -Float::INFINITY) + jd_jan1 = civil_to_jd(y, 1, 1, sg) + wday_jan1 = (jd_jan1 + 1) % 7 # 0=Sun + j = jd_jan1 - wday_jan1 + j += 1 if ws == 1 + j + 7 * w + d + end + + # n-th k-day (nth occurrence of weekday k in year y, month m) -> JD + # k: 0=Sun..6=Sat, n: positive from beginning, negative from end + def nth_kday_to_jd(y, m, n, k, sg) + if n > 0 + jd_m1 = civil_to_jd(y, m, 1, sg) + wday = (jd_m1 + 1) % 7 + diff = (k - wday + 7) % 7 + jd_m1 + diff + (n - 1) * 7 + else + # Last day of month + if m == 12 + jd_last = civil_to_jd(y + 1, 1, 1, sg) - 1 + else + jd_last = civil_to_jd(y, m + 1, 1, sg) - 1 + end + wday = (jd_last + 1) % 7 + diff = (wday - k + 7) % 7 + jd_last - diff + (n + 1) * 7 + end + end + + # --------------------------------------------------------------------------- + # Validation helpers + # --------------------------------------------------------------------------- + + def internal_valid_jd?(jd, _sg) + jd.is_a?(Numeric) ? jd.to_i : nil + end + + def internal_valid_civil?(y, m, d, sg) + return nil unless y.is_a?(Numeric) && m.is_a?(Numeric) && d.is_a?(Numeric) + y = y.to_i + m = m.to_i + d = d.to_i + # Handle negative month/day + m += 13 if m < 0 + return nil if m < 1 || m > 12 + # Days in that month + if sg == Float::INFINITY + dim = days_in_month_julian(y, m) + else + dim = days_in_month_gregorian(y, m) + end + d += dim + 1 if d < 0 + return nil if d < 1 || d > dim + civil_to_jd(y, m, d, sg) + end + + def internal_valid_ordinal?(y, yday, sg) + return nil unless y.is_a?(Numeric) && yday.is_a?(Numeric) + y = y.to_i + yday = yday.to_i + # Days in year + if sg == Float::INFINITY + diy = internal_julian_leap?(y) ? 366 : 365 + else + diy = internal_gregorian_leap?(y) ? 366 : 365 + end + yday += diy + 1 if yday < 0 + return nil if yday < 1 || yday > diy + ordinal_to_jd(y, yday, sg) + end + + def internal_valid_commercial?(y, w, d, sg) + return nil unless y.is_a?(Numeric) && w.is_a?(Numeric) && d.is_a?(Numeric) + y = y.to_i + w = w.to_i + d = d.to_i + # ISO cwday: 1=Mon..7=Sun + d += 8 if d < 0 + return nil if d < 1 || d > 7 + # Weeks in year: Dec 28 is always in the last ISO week + jd_dec28 = civil_to_jd(y, 12, 28, sg) + _, max_week, = jd_to_commercial(jd_dec28, sg) + w += max_week + 1 if w < 0 + return nil if w < 1 || w > max_week + commercial_to_jd(y, w, d, sg) + end + + # --------------------------------------------------------------------------- + # Internal object factory + # --------------------------------------------------------------------------- + + # Build a Date from a Julian Day Number (integer part), start, and optional day fraction. + def new_from_jd(jd, sg, df = nil) + obj = allocate + obj.__send__(:init_from_jd, jd, sg, df) + obj + end + + # Parse offset string like "+09:00", "-07:30", "Z" to seconds. + def offset_str_to_sec(str) + case str + when 'Z', 'z', 'UTC', 'GMT' + 0 + when /\A([+-])(\d{1,2}):?(\d{2})(?::(\d{2}))?\z/ + sign = $1 == '+' ? 1 : -1 + h, m, s = $2.to_i, $3.to_i, ($4 || '0').to_i + sign * (h * 3600 + m * 60 + s) + when /\A([+-])(\d{2})(\d{2})\z/ + sign = $1 == '+' ? 1 : -1 + h, m = $2.to_i, $3.to_i + sign * (h * 3600 + m * 60) + else + 0 + end + end + + end + + # --------------------------------------------------------------------------- + # Initializer + # --------------------------------------------------------------------------- + + # call-seq: + # Date.new(year = -4712, month = 1, mday = 1, start = Date::ITALY) -> date + # + # Returns a new \Date object constructed from the given arguments: + # + # Date.new(2022).to_s # => "2022-01-01" + # Date.new(2022, 2).to_s # => "2022-02-01" + # Date.new(2022, 2, 4).to_s # => "2022-02-04" + # + # Argument +month+ should be in range (1..12) or range (-12..-1); + # when the argument is negative, counts backward from the end of the year: + # + # Date.new(2022, -11, 4).to_s # => "2022-02-04" + # + # Argument +mday+ should be in range (1..n) or range (-n..-1) + # where +n+ is the number of days in the month; + # when the argument is negative, counts backward from the end of the month. + # + # See argument {start}[rdoc-ref:language/calendars.rdoc@Argument+start]. + # + # Related: Date.jd. + def initialize(year = -4712, month = 1, day = 1, start = DEFAULT_SG) + if Integer === year && Integer === month && Integer === day + m = month + m += 13 if m < 0 + if m >= 1 && m <= 12 + dim = if m == 2 + if start == Float::INFINITY + year % 4 == 0 ? 29 : 28 + else + (year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)) ? 29 : 28 + end + else + DAYS_IN_MONTH_GREGORIAN[m] + end + d = day + d += dim + 1 if d < 0 + if d >= 1 && d <= dim + @jd = self.class.__send__(:civil_to_jd, year, m, d, start) + @sg = start + @year = year + @month = m + @day = d + return + end + end + raise Date::Error, "invalid date" + end + year = Integer(year) + month = Integer(month) + day = Integer(day) + jd = self.class.__send__(:internal_valid_civil?, year, month, day, start) + raise Date::Error, "invalid date" unless jd + init_from_jd(jd, start) + end + + # --------------------------------------------------------------------------- + # Instance methods - basic attributes + # --------------------------------------------------------------------------- + + # call-seq: + # year -> integer + # + # Returns the year: + # + # Date.new(2001, 2, 3).year # => 2001 + # (Date.new(1, 1, 1) - 1).year # => 0 + # + def year + internal_civil unless @year + @year + end + + # call-seq: + # mon -> integer + # + # Returns the month in range (1..12): + # + # Date.new(2001, 2, 3).mon # => 2 + # + def month + internal_civil unless @year + @month + end + alias mon month + + # call-seq: + # mday -> integer + # + # Returns the day of the month in range (1..31): + # + # Date.new(2001, 2, 3).mday # => 3 + # + def day + internal_civil unless @year + @day + end + alias mday day + + # call-seq: + # d.jd -> integer + # + # Returns the Julian day number. This is a whole number, which is + # adjusted by the offset as the local time. + # + # DateTime.new(2001,2,3,4,5,6,'+7').jd #=> 2451944 + # DateTime.new(2001,2,3,4,5,6,'-7').jd #=> 2451944 + def jd + @jd + end + + # call-seq: + # start -> float + # + # Returns the Julian start date for calendar reform; + # if not an infinity, the returned value is suitable + # for passing to Date#jd: + # + # d = Date.new(2001, 2, 3, Date::ITALY) + # s = d.start # => 2299161.0 + # Date.jd(s).to_s # => "1582-10-15" + # + # d = Date.new(2001, 2, 3, Date::ENGLAND) + # s = d.start # => 2361222.0 + # Date.jd(s).to_s # => "1752-09-14" + # + # Date.new(2001, 2, 3, Date::GREGORIAN).start # => -Infinity + # Date.new(2001, 2, 3, Date::JULIAN).start # => Infinity + # + # See argument {start}[rdoc-ref:language/calendars.rdoc@Argument+start]. + # + def start + @sg + end + + # call-seq: + # d.ajd -> rational + # + # Returns the astronomical Julian day number. This is a fractional + # number, which is not adjusted by the offset. + # + # DateTime.new(2001,2,3,4,5,6,'+7').ajd #=> (11769328217/4800) + # DateTime.new(2001,2,2,14,5,6,'-7').ajd #=> (11769328217/4800) + def ajd + r = Rational(@jd * 2 - 1, 2) + @df ? r + @df : r + end + + # call-seq: + # d.amjd -> rational + # + # Returns the astronomical modified Julian day number. This is + # a fractional number, which is not adjusted by the offset. + # + # DateTime.new(2001,2,3,4,5,6,'+7').amjd #=> (249325817/4800) + # DateTime.new(2001,2,2,14,5,6,'-7').amjd #=> (249325817/4800) + def amjd + ajd - Rational(4800001, 2) + end + + # call-seq: + # d.mjd -> integer + # + # Returns the modified Julian day number. This is a whole number, + # which is adjusted by the offset as the local time. + # + # DateTime.new(2001,2,3,4,5,6,'+7').mjd #=> 51943 + # DateTime.new(2001,2,3,4,5,6,'-7').mjd #=> 51943 + def mjd + @jd - 2400001 + end + + # call-seq: + # ld -> integer + # + # Returns the + # {Lilian day number}[https://en.wikipedia.org/wiki/Lilian_date], + # which is the number of days since the beginning of the Gregorian + # calendar, October 15, 1582. + # + # Date.new(2001, 2, 3).ld # => 152784 + # + def ld + @jd - 2299160 + end + + # call-seq: + # yday -> integer + # + # Returns the day of the year, in range (1..366): + # + # Date.new(2001, 2, 3).yday # => 34 + # + def yday + return @yday if @yday + internal_civil unless @year + jd_jan1 = self.class.__send__(:civil_to_jd, @year, 1, 1, @sg) + val = @jd - jd_jan1 + 1 + @yday = val unless frozen? + val + end + + # call-seq: + # wday -> integer + # + # Returns the day of week in range (0..6); Sunday is 0: + # + # Date.new(2001, 2, 3).wday # => 6 + # + def wday + (@jd + 1) % 7 + end + + # call-seq: + # cwday -> integer + # + # Returns the commercial-date weekday index for +self+ + # (see Date.commercial); + # 1 is Monday: + # + # Date.new(2001, 2, 3).cwday # => 6 + # + def cwday + w = wday + w == 0 ? 7 : w + end + + # call-seq: + # cweek -> integer + # + # Returns commercial-date week index for +self+ + # (see Date.commercial): + # + # Date.new(2001, 2, 3).cweek # => 5 + # + def cweek + @cweek || compute_commercial[1] + end + + # call-seq: + # cwyear -> integer + # + # Returns commercial-date year for +self+ + # (see Date.commercial): + # + # Date.new(2001, 2, 3).cwyear # => 2001 + # Date.new(2000, 1, 1).cwyear # => 1999 + # + def cwyear + @cwyear || compute_commercial[0] + end + + # call-seq: + # day_fraction -> rational + # + # Returns the fractional part of the day in range (Rational(0, 1)...Rational(1, 1)): + # + # DateTime.new(2001,2,3,12).day_fraction # => (1/2) + # + def day_fraction + @df || 0r + end + + # call-seq: + # leap? -> true or false + # + # Returns +true+ if the year is a leap year, +false+ otherwise: + # + # Date.new(2000).leap? # => true + # Date.new(2001).leap? # => false + # + def leap? + internal_civil unless @year + if @jd < @sg # julian? + @year % 4 == 0 + else + (@year % 4 == 0 && @year % 100 != 0) || @year % 400 == 0 + end + end + + # call-seq: + # gregorian? -> true or false + # + # Returns +true+ if the date is on or after + # the date of calendar reform, +false+ otherwise: + # + # Date.new(1582, 10, 15).gregorian? # => true + # (Date.new(1582, 10, 15) - 1).gregorian? # => false + # + def gregorian? + jd >= @sg + end + + # call-seq: + # d.julian? -> true or false + # + # Returns +true+ if the date is before the date of calendar reform, + # +false+ otherwise: + # + # (Date.new(1582, 10, 15) - 1).julian? # => true + # Date.new(1582, 10, 15).julian? # => false + # + def julian? + !gregorian? + end + + # call-seq: + # new_start(start = Date::ITALY]) -> new_date + # + # Returns a copy of +self+ with the given +start+ value: + # + # d0 = Date.new(2000, 2, 3) + # d0.julian? # => false + # d1 = d0.new_start(Date::JULIAN) + # d1.julian? # => true + # + # See argument {start}[rdoc-ref:language/calendars.rdoc@Argument+start]. + # + def new_start(start = DEFAULT_SG) + obj = self.class.allocate + obj.instance_variable_set(:@jd, @jd) + obj.instance_variable_set(:@sg, start) + obj + end + + # call-seq: + # gregorian -> new_date + # + # Equivalent to Date#new_start with argument Date::GREGORIAN. + def gregorian + new_start(GREGORIAN) + end + + # call-seq: + # italy -> new_date + # + # Equivalent to Date#new_start with argument Date::ITALY. + # + def italy + new_start(ITALY) + end + + # call-seq: + # england -> new_date + # + # Equivalent to Date#new_start with argument Date::ENGLAND. + def england + new_start(ENGLAND) + end + + # call-seq: + # julian -> new_date + # + # Equivalent to Date#new_start with argument Date::JULIAN. + def julian + new_start(JULIAN) + end + + # call-seq: + # sunday? -> true or false + # + # Returns +true+ if +self+ is a Sunday, +false+ otherwise. + def sunday? + wday == 0 + end + # call-seq: + # monday? -> true or false + # + # Returns +true+ if +self+ is a Monday, +false+ otherwise. + def monday? + wday == 1 + end + # call-seq: + # tuesday? -> true or false + # + # Returns +true+ if +self+ is a Tuesday, +false+ otherwise. + def tuesday? + wday == 2 + end + # call-seq: + # wednesday? -> true or false + # + # Returns +true+ if +self+ is a Wednesday, +false+ otherwise. + def wednesday? + wday == 3 + end + # call-seq: + # thursday? -> true or false + # + # Returns +true+ if +self+ is a Thursday, +false+ otherwise. + def thursday? + wday == 4 + end + # call-seq: + # friday? -> true or false + # + # Returns +true+ if +self+ is a Friday, +false+ otherwise. + def friday? + wday == 5 + end + # call-seq: + # saturday? -> true or false + # + # Returns +true+ if +self+ is a Saturday, +false+ otherwise. + def saturday? + wday == 6 + end + + # :nodoc: + def nth_kday?(n, k) + return false if k != wday + jd_ref = self.class.__send__(:nth_kday_to_jd, year, month, n, k, @sg) + jd_ref == @jd + end + + # --------------------------------------------------------------------------- + # Comparison + # --------------------------------------------------------------------------- + + # call-seq: + # self <=> other -> -1, 0, 1 or nil + # + # Compares +self+ and +other+, returning: + # + # - -1 if +other+ is larger. + # - 0 if the two are equal. + # - 1 if +other+ is smaller. + # - +nil+ if the two are incomparable. + # + # Argument +other+ may be: + # + # - Another \Date object: + # + # d = Date.new(2022, 7, 27) # => # + # prev_date = d.prev_day # => # + # next_date = d.next_day # => # + # d <=> next_date # => -1 + # d <=> d # => 0 + # d <=> prev_date # => 1 + # + # - A DateTime object: + # + # d <=> DateTime.new(2022, 7, 26) # => 1 + # d <=> DateTime.new(2022, 7, 27) # => 0 + # d <=> DateTime.new(2022, 7, 28) # => -1 + # + # - A numeric (compares self.ajd to +other+): + # + # d <=> 2459788 # => -1 + # d <=> 2459787 # => 1 + # d <=> 2459786 # => 1 + # d <=> d.ajd # => 0 + # + # - Any other object: + # + # d <=> Object.new # => nil + # + def <=>(other) + case other + when Date + d = @jd <=> other.jd + d != 0 ? d : day_fraction <=> other.day_fraction + when Numeric + ajd <=> other + else + nil + end + end + + def <(other) + case other + when Date + d = @jd <=> other.jd + d != 0 ? d < 0 : day_fraction < other.day_fraction + when Numeric + r = ajd <=> other + raise ArgumentError, "comparison of #{self.class} with #{other.class} failed" if r.nil? + r < 0 + else + raise ArgumentError, "comparison of #{self.class} with #{other.class} failed" + end + end + + def >(other) + case other + when Date + d = @jd <=> other.jd + d != 0 ? d > 0 : day_fraction > other.day_fraction + when Numeric + r = ajd <=> other + raise ArgumentError, "comparison of #{self.class} with #{other.class} failed" if r.nil? + r > 0 + else + raise ArgumentError, "comparison of #{self.class} with #{other.class} failed" + end + end + + def ==(other) + case other + when Date + @jd == other.jd && day_fraction == other.day_fraction + when Numeric + ajd == other + else + false + end + end + + def eql?(other) + other.is_a?(Date) && @jd == other.jd && day_fraction == other.day_fraction + end + + def hash + [@jd, @sg].hash + end + + # call-seq: + # self === other -> true, false, or nil. + # + # Returns +true+ if +self+ and +other+ represent the same date, + # +false+ if not, +nil+ if the two are not comparable. + # + # Argument +other+ may be: + # + # - Another \Date object: + # + # d = Date.new(2022, 7, 27) # => # + # prev_date = d.prev_day # => # + # next_date = d.next_day # => # + # d === prev_date # => false + # d === d # => true + # d === next_date # => false + # + # - A DateTime object: + # + # d === DateTime.new(2022, 7, 26) # => false + # d === DateTime.new(2022, 7, 27) # => true + # d === DateTime.new(2022, 7, 28) # => false + # + # - A numeric (compares self.jd to +other+): + # + # d === 2459788 # => true + # d === 2459787 # => false + # d === 2459786 # => false + # d === d.jd # => true + # + # - An object not comparable: + # + # d === Object.new # => nil + # + def ===(other) + case other + when Numeric + jd == other.to_i + when Date + jd == other.jd + else + nil + end + end + + # --------------------------------------------------------------------------- + # Arithmetic + # --------------------------------------------------------------------------- + + # call-seq: + # d + other -> date + # + # Returns a date object pointing +other+ days after self. The other + # should be a numeric value. If the other is a fractional number, + # assumes its precision is at most nanosecond. + # + # Date.new(2001,2,3) + 1 #=> # + # DateTime.new(2001,2,3) + Rational(1,2) + # #=> # + # DateTime.new(2001,2,3) + Rational(-1,2) + # #=> # + # DateTime.jd(0,12) + DateTime.new(2001,2,3).ajd + # #=> # + def +(other) + case other + when Integer + if instance_of?(Date) + obj = Date.allocate + obj.instance_variable_set(:@jd, @jd + other) + obj.instance_variable_set(:@sg, @sg) + obj + else + self.class.__send__(:new_from_jd, @jd + other, @sg, @df) + end + when Numeric + r = other.to_r + raise TypeError, "#{other.class} can't be coerced into Integer" unless r.is_a?(Rational) + total = r + (@df || 0) + days = total.floor + frac = total - days + self.class.__send__(:new_from_jd, @jd + days, @sg, frac == 0 ? nil : frac) + else + raise TypeError, "expected numeric" + end + end + + # call-seq: + # d - other -> date or rational + # + # If the other is a date object, returns a Rational + # whose value is the difference between the two dates in days. + # If the other is a numeric value, returns a date object + # pointing +other+ days before self. + # If the other is a fractional number, + # assumes its precision is at most nanosecond. + # + # Date.new(2001,2,3) - 1 #=> # + # DateTime.new(2001,2,3) - Rational(1,2) + # #=> # + # Date.new(2001,2,3) - Date.new(2001) + # #=> (33/1) + # DateTime.new(2001,2,3) - DateTime.new(2001,2,2,12) + # #=> (1/2) + def -(other) + case other + when Date + Rational(@jd - other.jd) + (@df || 0) - other.day_fraction + when Integer + if instance_of?(Date) + obj = Date.allocate + obj.instance_variable_set(:@jd, @jd - other) + obj.instance_variable_set(:@sg, @sg) + obj + else + self.class.__send__(:new_from_jd, @jd - other, @sg, @df) + end + when Numeric + r = other.to_r + raise TypeError, "#{other.class} can't be coerced into Integer" unless r.is_a?(Rational) + total = (@df || 0) - r + days = total.floor + frac = total - days + self.class.__send__(:new_from_jd, @jd + days, @sg, frac == 0 ? nil : frac) + else + raise TypeError, "expected numeric" + end + end + + # call-seq: + # d >> n -> new_date + # + # Returns a new \Date object representing the date + # +n+ months later; +n+ should be a numeric: + # + # (Date.new(2001, 2, 3) >> 1).to_s # => "2001-03-03" + # (Date.new(2001, 2, 3) >> -2).to_s # => "2000-12-03" + # + # When the same day does not exist for the new month, + # the last day of that month is used instead: + # + # (Date.new(2001, 1, 31) >> 1).to_s # => "2001-02-28" + # (Date.new(2001, 1, 31) >> -4).to_s # => "2000-09-30" + # + # This results in the following, possibly unexpected, behaviors: + # + # d0 = Date.new(2001, 1, 31) + # d1 = d0 >> 1 # => # + # d2 = d1 >> 1 # => # + # + # d0 = Date.new(2001, 1, 31) + # d1 = d0 >> 1 # => # + # d2 = d1 >> -1 # => # + # + def >>(n) + internal_civil unless @year + m2 = @month + n.to_i + y2 = @year + (m2 - 1).div(12) + m2 = (m2 - 1) % 12 + 1 + if @sg == Float::INFINITY + dim = self.class.__send__(:days_in_month_julian, y2, m2) + else + dim = self.class.__send__(:days_in_month_gregorian, y2, m2) + end + d2 = @day < dim ? @day : dim + jd2 = self.class.__send__(:civil_to_jd, y2, m2, d2, @sg) + self.class.__send__(:new_from_jd, jd2, @sg) + end + + # call-seq: + # d << n -> date + # + # Returns a new \Date object representing the date + # +n+ months earlier; +n+ should be a numeric: + # + # (Date.new(2001, 2, 3) << 1).to_s # => "2001-01-03" + # (Date.new(2001, 2, 3) << -2).to_s # => "2001-04-03" + # + # When the same day does not exist for the new month, + # the last day of that month is used instead: + # + # (Date.new(2001, 3, 31) << 1).to_s # => "2001-02-28" + # (Date.new(2001, 3, 31) << -6).to_s # => "2001-09-30" + # + # This results in the following, possibly unexpected, behaviors: + # + # d0 = Date.new(2001, 3, 31) + # d0 << 2 # => # + # d0 << 1 << 1 # => # + # + # d0 = Date.new(2001, 3, 31) + # d1 = d0 << 1 # => # + # d2 = d1 << -1 # => # + # + def <<(n) + self >> -n + end + + # call-seq: + # next_day(n = 1) -> new_date + # + # Equivalent to Date#+ with argument +n+. + def next_day(n = 1) + self + n + end + + # call-seq: + # prev_day(n = 1) -> new_date + # + # Equivalent to Date#- with argument +n+. + def prev_day(n = 1) + self - n + end + + # call-seq: + # d.next -> new_date + # + # Returns a new \Date object representing the following day: + # + # d = Date.new(2001, 2, 3) + # d.to_s # => "2001-02-03" + # d.next.to_s # => "2001-02-04" + # + def next + self + 1 + end + alias_method :succ, :next + + # call-seq: + # next_year(n = 1) -> new_date + # + # Equivalent to #>> with argument n * 12. + def next_year(n = 1) + self >> n * 12 + end + + # call-seq: + # prev_year(n = 1) -> new_date + # + # Equivalent to #<< with argument n * 12. + def prev_year(n = 1) + self << n * 12 + end + + # call-seq: + # next_month(n = 1) -> new_date + # + # Equivalent to #>> with argument +n+. + def next_month(n = 1) + self >> n + end + + # call-seq: + # prev_month(n = 1) -> new_date + # + # Equivalent to #<< with argument +n+. + def prev_month(n = 1) + self << n + end + + # call-seq: + # step(limit, step = 1){|date| ... } -> self + # + # Calls the block with specified dates; + # returns +self+. + # + # - The first +date+ is +self+. + # - Each successive +date+ is date + step, + # where +step+ is the numeric step size in days. + # - The last date is the last one that is before or equal to +limit+, + # which should be a \Date object. + # + # Example: + # + # limit = Date.new(2001, 12, 31) + # Date.new(2001).step(limit){|date| p date.to_s if date.mday == 31 } + # + # Output: + # + # "2001-01-31" + # "2001-03-31" + # "2001-05-31" + # "2001-07-31" + # "2001-08-31" + # "2001-10-31" + # "2001-12-31" + # + # Returns an Enumerator if no block is given. + def step(limit, step = 1) + return to_enum(:step, limit, step) unless block_given? + if Integer === step && instance_of?(Date) && limit.instance_of?(Date) + raise ArgumentError, "step can't be 0" if step == 0 + limit_jd = limit.jd + sg = @sg + if step > 0 + jd = @jd + while jd <= limit_jd + obj = Date.allocate + obj.instance_variable_set(:@jd, jd) + obj.instance_variable_set(:@sg, sg) + yield obj + jd += step + end + else + jd = @jd + while jd >= limit_jd + obj = Date.allocate + obj.instance_variable_set(:@jd, jd) + obj.instance_variable_set(:@sg, sg) + yield obj + jd += step + end + end + return self + end + d = self + cmp = step <=> 0 + raise ArgumentError, "comparison of #{step.class} with 0 failed" if cmp.nil? + if cmp > 0 + while d <= limit + yield d + d = d + step + end + elsif cmp < 0 + while d >= limit + yield d + d = d + step + end + else + raise ArgumentError, "step can't be 0" + end + self + end + + # call-seq: + # upto(max){|date| ... } -> self + # + # Equivalent to #step with arguments +max+ and +1+. + def upto(max, &block) + return to_enum(:upto, max) unless block_given? + if instance_of?(Date) && max.instance_of?(Date) + jd = @jd + max_jd = max.jd + sg = @sg + while jd <= max_jd + obj = Date.allocate + obj.instance_variable_set(:@jd, jd) + obj.instance_variable_set(:@sg, sg) + yield obj + jd += 1 + end + return self + end + step(max, 1, &block) + end + + # call-seq: + # downto(min){|date| ... } -> self + # + # Equivalent to #step with arguments +min+ and -1. + def downto(min, &block) + return to_enum(:downto, min) unless block_given? + if instance_of?(Date) && min.instance_of?(Date) + jd = @jd + min_jd = min.jd + sg = @sg + while jd >= min_jd + obj = Date.allocate + obj.instance_variable_set(:@jd, jd) + obj.instance_variable_set(:@sg, sg) + yield obj + jd -= 1 + end + return self + end + step(min, -1, &block) + end + + # --------------------------------------------------------------------------- + # Calendar conversion + # --------------------------------------------------------------------------- + + # call-seq: + # to_date -> self + # + # Returns +self+. + def to_date + self + end + + # call-seq: + # d.to_datetime -> datetime + # + # Returns a DateTime whose value is the same as +self+: + # + # Date.new(2001, 2, 3).to_datetime # => # + # + def to_datetime + DateTime.new(year, month, day, 0, 0, 0, 0, @sg) + end + + # call-seq: + # to_time -> time + # + # Returns a new Time object with the same value as +self+; + # if +self+ is a Julian date, derives its Gregorian date + # for conversion to the \Time object: + # + # Date.new(2001, 2, 3).to_time # => 2001-02-03 00:00:00 -0600 + # Date.new(2001, 2, 3, Date::JULIAN).to_time # => 2001-02-16 00:00:00 -0600 + # + def to_time + year, month, day = self.class.__send__(:jd_to_gregorian, @jd) + Time.local(year, month, day) + end + + # --------------------------------------------------------------------------- + # Serialization + # --------------------------------------------------------------------------- + + # :nodoc: + def initialize_copy(other) + @jd = other.instance_variable_get(:@jd) + @sg = other.instance_variable_get(:@sg) + @df = other.instance_variable_get(:@df) + @year = other.instance_variable_get(:@year) + @month = other.instance_variable_get(:@month) + @day = other.instance_variable_get(:@day) + end + + # :nodoc: + def marshal_dump + # 6-element format: [nth, jd, df, sf, of, sg] + # df = seconds into day (Integer), sf = sub-second fraction (Rational) + if @df + total_sec = @df * 86400 + df_int = total_sec.floor + sf = total_sec - df_int + [0, @jd, df_int, sf, 0, @sg] + else + [0, @jd, 0, 0, 0, @sg] + end + end + + # :nodoc: + def marshal_load(array) + case array.length + when 2 + # Format 1.4/1.6: [jd_like, sg_or_bool] + jd_like, sg_or_bool = array + sg = sg_or_bool == true ? GREGORIAN : (sg_or_bool == false ? JULIAN : sg_or_bool.to_f) + init_from_jd(jd_like.to_i, sg) + when 3 + # Format 1.8: [ajd, of, sg] + ajd, _of, sg = array + raw_jd = ajd + 0.5r + jd = raw_jd.floor + df = raw_jd - jd + init_from_jd(jd, sg, df == 0 ? nil : df) + when 6 + # Current format: [nth, jd, df, sf, of, sg] + _nth, jd, df, sf, _of, sg = array + if df != 0 || sf != 0 + day_frac = (Rational(df) + sf) / 86400 + init_from_jd(jd, sg, day_frac) + else + init_from_jd(jd, sg) + end + else + raise TypeError, "invalid marshal data" + end + end + + # call-seq: + # deconstruct_keys(array_of_names_or_nil) -> hash + # + # Returns a hash of the name/value pairs, to use in pattern matching. + # Possible keys are: :year, :month, :day, + # :wday, :yday. + # + # Possible usages: + # + # d = Date.new(2022, 10, 5) + # + # if d in wday: 3, day: ..7 # uses deconstruct_keys underneath + # puts "first Wednesday of the month" + # end + # #=> prints "first Wednesday of the month" + # + # case d + # in year: ...2022 + # puts "too old" + # in month: ..9 + # puts "quarter 1-3" + # in wday: 1..5, month: + # puts "working day in month #{month}" + # end + # #=> prints "working day in month 10" + # + # Note that deconstruction by pattern can also be combined with class check: + # + # if d in Date(wday: 3, day: ..7) + # puts "first Wednesday of the month" + # end + # + def deconstruct_keys(keys) + if keys + if keys.size == 1 + case keys[0] + when :year + internal_civil unless @year + { year: @year } + when :month + internal_civil unless @year + { month: @month } + when :day + internal_civil unless @year + { day: @day } + when :wday then { wday: (@jd + 1) % 7 } + when :yday then { yday: yday } + else {} + end + else + internal_civil unless @year + h = {} + keys.each do |k| + case k + when :year then h[:year] = @year + when :month then h[:month] = @month + when :day then h[:day] = @day + when :wday then h[:wday] = (@jd + 1) % 7 + when :yday then h[:yday] = yday + end + end + h + end + else + internal_civil unless @year + { year: @year, month: @month, day: @day, wday: (@jd + 1) % 7, yday: yday } + end + end + + # --------------------------------------------------------------------------- + # String formatting (delegated to strftime.rb) + # --------------------------------------------------------------------------- + + # call-seq: + # asctime -> string + # + # Equivalent to #strftime with argument '%a %b %e %T %Y' + # (or its {shorthand form}[rdoc-ref:language/strftime_formatting.rdoc@Shorthand+Conversion+Specifiers] + # '%c'): + # + # Date.new(2001, 2, 3).asctime # => "Sat Feb 3 00:00:00 2001" + # + # See {asctime}[https://linux.die.net/man/3/asctime]. + # + def asctime + internal_civil unless @year + d = @day + d_s = d < 10 ? " #{d}" : d.to_s + y = @year + y_s = y >= 1000 ? y.to_s : (y >= 0 ? format('%04d', y) : format('-%04d', -y)) + w = (@jd + 1) % 7 + if instance_of?(Date) + "#{ASCTIME_DAYS[w]} #{ASCTIME_MONS[@month]} #{d_s} 00:00:00 #{y_s}".force_encoding(Encoding::US_ASCII) + else + "#{ASCTIME_DAYS[w]} #{ASCTIME_MONS[@month]} #{d_s} #{PAD2[internal_hour]}:#{PAD2[internal_min]}:#{PAD2[internal_sec]} #{y_s}".force_encoding(Encoding::US_ASCII) + end + end + alias_method :ctime, :asctime + + # call-seq: + # iso8601 -> string + # + # Equivalent to #strftime with argument '%Y-%m-%d' + # (or its {shorthand form}[rdoc-ref:language/strftime_formatting.rdoc@Shorthand+Conversion+Specifiers] + # '%F'); + # + # Date.new(2001, 2, 3).iso8601 # => "2001-02-03" + # + def iso8601 + to_s + end + alias_method :xmlschema, :iso8601 + + # call-seq: + # rfc3339 -> string + # + # Equivalent to #strftime with argument '%FT%T%:z'; + # see {Formats for Dates and Times}[rdoc-ref:language/strftime_formatting.rdoc]: + # + # Date.new(2001, 2, 3).rfc3339 # => "2001-02-03T00:00:00+00:00" + # + def rfc3339 + (to_s << 'T00:00:00+00:00').force_encoding(Encoding::US_ASCII) + end + + # call-seq: + # rfc2822 -> string + # + # Equivalent to #strftime with argument '%a, %-d %b %Y %T %z'; + # see {Formats for Dates and Times}[rdoc-ref:language/strftime_formatting.rdoc]: + # + # Date.new(2001, 2, 3).rfc2822 # => "Sat, 3 Feb 2001 00:00:00 +0000" + # + def rfc2822 + internal_civil unless @year + w = (@jd + 1) % 7 + y = @year + y_s = y >= 1000 ? y.to_s : (y >= 0 ? format('%04d', y) : format('-%04d', -y)) + if instance_of?(Date) + "#{RFC2822_DAYS[w]}, #{@day}#{RFC_MON_SPACE[@month]}#{y_s} 00:00:00 +0000".force_encoding(Encoding::US_ASCII) + else + "#{RFC2822_DAYS[w]}, #{@day}#{RFC_MON_SPACE[@month]}#{y_s} #{PAD2[internal_hour]}:#{PAD2[internal_min]}:#{PAD2[internal_sec]} +0000".force_encoding(Encoding::US_ASCII) + end + end + alias_method :rfc822, :rfc2822 + + # call-seq: + # httpdate -> string + # + # Equivalent to #strftime with argument '%a, %d %b %Y %T GMT'; + # see {Formats for Dates and Times}[rdoc-ref:language/strftime_formatting.rdoc]: + # + # Date.new(2001, 2, 3).httpdate # => "Sat, 03 Feb 2001 00:00:00 GMT" + # + def httpdate + internal_civil unless @year + w = (@jd + 1) % 7 + y = @year + y_s = y >= 1000 ? y.to_s : (y >= 0 ? format('%04d', y) : format('-%04d', -y)) + if instance_of?(Date) + "#{ASCTIME_DAYS[w]}, #{PAD2[@day]}#{RFC_MON_SPACE[@month]}#{y_s} 00:00:00 GMT".force_encoding(Encoding::US_ASCII) + else + "#{ASCTIME_DAYS[w]}, #{PAD2[@day]}#{RFC_MON_SPACE[@month]}#{y_s} #{PAD2[internal_hour]}:#{PAD2[internal_min]}:#{PAD2[internal_sec]} GMT".force_encoding(Encoding::US_ASCII) + end + end + + # call-seq: + # jisx0301 -> string + # + # Returns a string representation of the date in +self+ + # in JIS X 0301 format. + # + # Date.new(2001, 2, 3).jisx0301 # => "H13.02.03" + # + def jisx0301 + internal_civil unless @year + jd = @jd + m = @month + d = @day + md = "#{PAD2[m]}.#{PAD2[d]}" + if jd >= 2458605 # Reiwa (2019-05-01) + "R#{PAD2[@year - 2018]}.#{md}" + elsif jd >= 2447535 # Heisei (1989-01-08) + "H#{PAD2[@year - 1988]}.#{md}" + elsif jd >= 2424875 # Showa (1926-12-25) + "S#{PAD2[@year - 1925]}.#{md}" + elsif jd >= 2419614 # Taisho (1912-07-30) + "T#{PAD2[@year - 1911]}.#{md}" + elsif jd >= 2405160 # Meiji (1873-01-01) + "M#{PAD2[@year - 1867]}.#{md}" + else + to_s + end + end + + # call-seq: + # to_s -> string + # + # Returns a string representation of the date in +self+ + # in {ISO 8601 extended date format}[rdoc-ref:language/strftime_formatting.rdoc@ISO+8601+Format+Specifications] + # ('%Y-%m-%d'): + # + # Date.new(2001, 2, 3).to_s # => "2001-02-03" + # + def to_s + internal_civil + suffix = MONTH_DAY_SUFFIX[@month][@day] + y = @year + if y >= 1000 + # Fast path: 4-digit year needs no zero-padding (most common case). + (y.to_s << suffix).force_encoding(Encoding::US_ASCII) + elsif y >= 0 + (format('%04d', y) << suffix).force_encoding(Encoding::US_ASCII) + else + (format('-%04d', -y) << suffix).force_encoding(Encoding::US_ASCII) + end + end + + # call-seq: + # inspect -> string + # + # Returns a string representation of +self+: + # + # Date.new(2001, 2, 3).inspect + # # => "#" + # + def inspect + sg = @sg.is_a?(Float) ? @sg.to_s : @sg + "#".force_encoding(Encoding::US_ASCII) + end + + # override + def freeze + internal_civil # compute and cache civil date before freezing + super + end + + # --------------------------------------------------------------------------- + # Private helpers + # --------------------------------------------------------------------------- + + private + + def init_from_jd(jd, sg, df = nil) + @jd = jd + @sg = sg + @df = df + end + + def internal_civil + return if @year + jd = @jd + if @sg == Float::INFINITY # always Julian + b = 0 + c = jd + 32082 + elsif jd >= @sg # Gregorian (handles -Infinity too) + a = jd + 32044 + b = (4 * a + 3) / 146097 + c = a - (146097 * b) / 4 + else # Julian (before reform date) + b = 0 + c = jd + 32082 + end + d = (4 * c + 3) / 1461 + e = c - (1461 * d) / 4 + m = (5 * e + 2) / 153 + @day = e - (153 * m + 2) / 5 + 1 + @month = m + 3 - 12 * (m / 10) + @year = 100 * b + d - 4800 + m / 10 + end + + def compute_commercial + y, cw, = self.class.__send__(:jd_to_commercial, @jd, @sg) + unless frozen? + @cweek = cw + @cwyear = y + end + [y, cw] + end + +end diff --git a/lib/date/datetime.rb b/lib/date/datetime.rb new file mode 100644 index 00000000..61a69d63 --- /dev/null +++ b/lib/date/datetime.rb @@ -0,0 +1,974 @@ +# frozen_string_literal: true + +class DateTime < Date + + STRFTIME_DATETIME_DEFAULT_FMT = '%FT%T%:z'.encode(Encoding::US_ASCII) + private_constant :STRFTIME_DATETIME_DEFAULT_FMT + + # --------------------------------------------------------------------------- + # Initializer + # --------------------------------------------------------------------------- + + # call-seq: + # DateTime.new(year=-4712, month=1, day=1, hour=0, minute=0, second=0, offset=0, start=Date::ITALY) -> datetime + def initialize(year = -4712, month = 1, day = 1, hour = 0, minute = 0, second = 0, offset = 0, start = ITALY) + year = Integer(year) + month = Integer(month) + of_sec = _str_offset_to_sec(offset) + + raise TypeError, "expected numeric" unless day.is_a?(Numeric) + + # Fractional day/hour/minute: propagate fraction to smaller units + day_r = day.to_r + day_i = day_r.floor + day_frac = day_r - day_i + day = day_i + + jd = self.class.__send__(:internal_valid_civil?, year, month, day, start) + raise Date::Error, "invalid date" unless jd + + raise TypeError, "expected numeric" unless hour.is_a?(Numeric) + raise TypeError, "expected numeric" unless minute.is_a?(Numeric) + raise TypeError, "expected numeric" unless second.is_a?(Numeric) + + # Propagate fractions to smaller units + hour_r = hour.to_r + day_frac * 24 + hour_i = hour_r.floor + hour_frac = hour_r - hour_i + + minute_r = minute.to_r + hour_frac * 60 + minute_i = minute_r.floor + minute_frac = minute_r - minute_i + + second_r = second.to_r + minute_frac * 60 + sec_i = second_r.floor + sec_f = second_r - sec_i + jd, hour, minute, sec_i = self.class.__send__(:_normalize_hms, jd, hour_i, minute_i, sec_i) + + _init_datetime(jd, hour, minute, sec_i, sec_f, of_sec, start) + end + + # --------------------------------------------------------------------------- + # Instance attributes + # --------------------------------------------------------------------------- + + # call-seq: + # hour -> integer + # + # Returns the hour in range (0..23): + # + # DateTime.new(2001, 2, 3, 4, 5, 6).hour # => 4 + def hour + @hour + end + + # call-seq: + # min -> integer + # + # Returns the minute in range (0..59): + # + # DateTime.new(2001, 2, 3, 4, 5, 6).min # => 5 + def min + @min + end + alias minute min + + # call-seq: + # sec -> integer + # + # Returns the second in range (0..59): + # + # DateTime.new(2001, 2, 3, 4, 5, 6).sec # => 6 + def sec + @sec_i + end + alias second sec + + # call-seq: + # sec_fraction -> rational + # + # Returns the fractional part of the second in range + # (Rational(0, 1)...Rational(1, 1)): + # + # DateTime.new(2001, 2, 3, 4, 5, 6.5).sec_fraction # => (1/2) + def sec_fraction + @sec_frac + end + alias second_fraction sec_fraction + + # call-seq: + # d.offset -> rational + # + # Returns the offset. + # + # DateTime.parse('04pm+0730').offset #=> (5/16) + def offset + Rational(@of, 86400) + end + + # call-seq: + # d.zone -> string + # + # Returns the timezone. + # + # DateTime.parse('04pm+0730').zone #=> "+07:30" + def zone + _of2str(@of) + end + + # call-seq: + # day_fraction -> rational + # + # Returns the fractional part of the day in range (Rational(0, 1)...Rational(1, 1)): + # + # DateTime.new(2001,2,3,12).day_fraction # => (1/2) + def day_fraction + Rational(@hour * 3600 + @min * 60 + @sec_i, 86400) + + Rational(@sec_frac.numerator, @sec_frac.denominator * 86400) + end + + # call-seq: + # d.ajd -> rational + # + # Returns the astronomical Julian day number. This is a fractional + # number, which is not adjusted by the offset. + # + # DateTime.new(2001,2,3,4,5,6,'+7').ajd #=> (11769328217/4800) + # DateTime.new(2001,2,2,14,5,6,'-7').ajd #=> (11769328217/4800) + def ajd + jd_r = Rational(@jd) + time_r = Rational(@hour * 3600 + @min * 60 + @sec_i, 86400) + + Rational(@sec_frac.numerator, @sec_frac.denominator * 86400) + of_r = Rational(@of, 86400) + jd_r + time_r - of_r - 0.5r + end + + # --------------------------------------------------------------------------- + # Arithmetic (override for fractional day support) + # --------------------------------------------------------------------------- + + # call-seq: + # d + other -> date + # + # Returns a date object pointing +other+ days after self. The other + # should be a numeric value. If the other is a fractional number, + # assumes its precision is at most nanosecond. + # + # Date.new(2001,2,3) + 1 #=> # + # DateTime.new(2001,2,3) + Rational(1,2) + # #=> # + # DateTime.new(2001,2,3) + Rational(-1,2) + # #=> # + # DateTime.jd(0,12) + DateTime.new(2001,2,3).ajd + # #=> # + def +(other) + case other + when Integer + self.class.__send__(:_new_dt_from_jd_time,@jd + other, @hour, @min, @sec_i, @sec_frac, @of, @sg) + when Rational, Float + # other is days (may be fractional) — add as seconds + extra_sec = other.to_r * 86400 + total_r = Rational(@jd) * 86400 + @hour * 3600 + @min * 60 + @sec_i + @sec_frac + extra_sec + _from_total_sec_r(total_r) + when Numeric + r = other.to_r + raise TypeError, "#{other.class} can't be coerced into Integer" unless r.is_a?(Rational) + extra_sec = r * 86400 + total_r = Rational(@jd) * 86400 + @hour * 3600 + @min * 60 + @sec_i + @sec_frac + extra_sec + _from_total_sec_r(total_r) + else + raise TypeError, "expected numeric" + end + end + + # call-seq: + # d - other -> date or rational + # + # If the other is a date object, returns a Rational + # whose value is the difference between the two dates in days. + # If the other is a numeric value, returns a date object + # pointing +other+ days before self. + # If the other is a fractional number, + # assumes its precision is at most nanosecond. + # + # Date.new(2001,2,3) - 1 #=> # + # DateTime.new(2001,2,3) - Rational(1,2) + # #=> # + # Date.new(2001,2,3) - Date.new(2001) + # #=> (33/1) + # DateTime.new(2001,2,3) - DateTime.new(2001,2,2,12) + # #=> (1/2) + def -(other) + case other + when Date + ajd - other.ajd + when Integer + self.class.__send__(:_new_dt_from_jd_time,@jd - other, @hour, @min, @sec_i, @sec_frac, @of, @sg) + when Rational, Float + self + (-other) + when Numeric + r = other.to_r + raise TypeError, "#{other.class} can't be coerced into Integer" unless r.is_a?(Rational) + self + (-r) + else + raise TypeError, "expected numeric" + end + end + + # call-seq: + # new_start(start = Date::ITALY]) -> new_date + # + # Returns a copy of +self+ with the given +start+ value: + # + # d0 = Date.new(2000, 2, 3) + # d0.julian? # => false + # d1 = d0.new_start(Date::JULIAN) + # d1.julian? # => true + # + # See argument {start}[rdoc-ref:language/calendars.rdoc@Argument+start]. + def new_start(start = Date::ITALY) + self.class.__send__(:_new_dt_from_jd_time, @jd, @hour, @min, @sec_i, @sec_frac, @of, start) + end + + # call-seq: + # d.new_offset([offset=0]) -> date + # + # Duplicates self and resets its offset. + # + # d = DateTime.new(2001,2,3,4,5,6,'-02:00') + # #=> # + # d.new_offset('+09:00') #=> # + def new_offset(of = 0) + of_sec = _str_offset_to_sec(of) + self.class.__send__(:_new_dt_from_jd_time,@jd, @hour, @min, @sec_i, @sec_frac, of_sec, @sg) + end + + # --------------------------------------------------------------------------- + # String formatting + # --------------------------------------------------------------------------- + + # call-seq: + # strftime(format = '%FT%T%:z') -> string + # + # Returns a string representation of +self+, + # formatted according the given +format: + # + # DateTime.now.strftime # => "2022-07-01T11:03:19-05:00" + # + # For other formats, + # see {Formats for Dates and Times}[rdoc-ref:language/strftime_formatting.rdoc]: + def strftime(format = STRFTIME_DATETIME_DEFAULT_FMT) + super(format) + end + + # call-seq: + # dt.jisx0301([n=0]) -> string + # + # Returns a string in a JIS X 0301 format. + # The optional argument +n+ is the number of digits for fractional seconds. + # + # DateTime.parse('2001-02-03T04:05:06.123456789+07:00').jisx0301(9) + # #=> "H13.02.03T04:05:06.123456789+07:00" + def jisx0301(n = 0) + n = n.to_i + ERA_TABLE.each do |start_jd, era, base_year| + if @jd >= start_jd + era_year = year - base_year + if n == 0 + return format('%s%02d.%02d.%02dT%02d:%02d:%02d%s', + era, era_year, month, day, hour, min, sec, zone) + else + sf = sec_fraction + frac = '.' + (sf * (10**n)).to_i.to_s.rjust(n, '0') + return format('%s%02d.%02d.%02dT%02d:%02d:%02d%s%s', + era, era_year, month, day, hour, min, sec, frac, zone) + end + end + end + iso8601(n) + end + + # call-seq: + # dt.iso8601([n=0]) -> string + # dt.xmlschema([n=0]) -> string + # + # This method is equivalent to strftime('%FT%T%:z'). + # The optional argument +n+ is the number of digits for fractional seconds. + # + # DateTime.parse('2001-02-03T04:05:06.123456789+07:00').iso8601(9) + # #=> "2001-02-03T04:05:06.123456789+07:00" + def iso8601(n = 0) + n = n.to_i + if n == 0 + strftime('%Y-%m-%dT%H:%M:%S%:z') + else + sf = sec_fraction + frac = '.' + (sf * (10**n)).to_i.to_s.rjust(n, '0') + strftime("%Y-%m-%dT%H:%M:%S#{frac}%:z") + end + end + alias_method :xmlschema, :iso8601 + + # call-seq: + # dt.rfc3339([n=0]) -> string + # + # This method is equivalent to strftime('%FT%T%:z'). + # The optional argument +n+ is the number of digits for fractional seconds. + # + # DateTime.parse('2001-02-03T04:05:06.123456789+07:00').rfc3339(9) + # #=> "2001-02-03T04:05:06.123456789+07:00" + alias_method :rfc3339, :iso8601 + + # call-seq: + # deconstruct_keys(array_of_names_or_nil) -> hash + # + # Returns a hash of the name/value pairs, to use in pattern matching. + # Possible keys are: :year, :month, :day, + # :wday, :yday, :hour, :min, + # :sec, :sec_fraction, :zone. + # + # Possible usages: + # + # dt = DateTime.new(2022, 10, 5, 13, 30) + # + # if d in wday: 1..5, hour: 10..18 # uses deconstruct_keys underneath + # puts "Working time" + # end + # #=> prints "Working time" + # + # case dt + # in year: ...2022 + # puts "too old" + # in month: ..9 + # puts "quarter 1-3" + # in wday: 1..5, month: + # puts "working day in month #{month}" + # end + # #=> prints "working day in month 10" + # + # Note that deconstruction by pattern can also be combined with class check: + # + # if d in DateTime(wday: 1..5, hour: 10..18, day: ..7) + # puts "Working time, first week of the month" + # end + def deconstruct_keys(keys) + if keys + if keys.size == 1 + case keys[0] + when :year + internal_civil unless @year + { year: @year } + when :month + internal_civil unless @year + { month: @month } + when :day + internal_civil unless @year + { day: @day } + when :wday then { wday: (@jd + 1) % 7 } + when :yday then { yday: yday } + when :hour then { hour: @hour } + when :min then { min: @min } + when :sec then { sec: @sec_i } + when :sec_fraction then { sec_fraction: @sec_frac } + when :zone then { zone: _of2str(@of) } + else {} + end + else + internal_civil unless @year + h = {} + keys.each do |k| + case k + when :year then h[:year] = @year + when :month then h[:month] = @month + when :day then h[:day] = @day + when :wday then h[:wday] = (@jd + 1) % 7 + when :yday then h[:yday] = yday + when :hour then h[:hour] = @hour + when :min then h[:min] = @min + when :sec then h[:sec] = @sec_i + when :sec_fraction then h[:sec_fraction] = @sec_frac + when :zone then h[:zone] = _of2str(@of) + end + end + h + end + else + internal_civil unless @year + { year: @year, month: @month, day: @day, wday: (@jd + 1) % 7, yday: yday, + hour: @hour, min: @min, sec: @sec_i, sec_fraction: @sec_frac, zone: _of2str(@of) } + end + end + + DATETIME_TO_S_FMT = '%Y-%m-%dT%H:%M:%S%:z'.encode(Encoding::US_ASCII).freeze + private_constant :DATETIME_TO_S_FMT + + # call-seq: + # dt.to_s -> string + # + # Returns a string in an ISO 8601 format. (This method doesn't use the + # expanded representations.) + # + # DateTime.new(2001,2,3,4,5,6,'-7').to_s + # #=> "2001-02-03T04:05:06-07:00" + def to_s + strftime(DATETIME_TO_S_FMT) + end + + def hash + if @hour == 0 && @min == 0 && @sec_i == 0 + [@jd, @sg].hash + else + [@jd, @hour, @min, @sec_i, @sg].hash + end + end + + # --------------------------------------------------------------------------- + # Serialization override + # --------------------------------------------------------------------------- + + # :nodoc: + def marshal_dump + # 6-element format: [nth, jd, df, sf, of, sg] + df = @hour * 3600 + @min * 60 + @sec_i + sf = (@sec_frac * 1_000_000_000).to_r # nanoseconds as Rational + [0, @jd, df, sf, @of, @sg] + end + + # :nodoc: + def marshal_load(array) + case array.length + when 2 + jd_like, sg_or_bool = array + sg = sg_or_bool == true ? ITALY : (sg_or_bool == false ? JULIAN : sg_or_bool.to_f) + _init_datetime(jd_like.to_i, 0, 0, 0, 0r, 0, sg) + when 3 + ajd, of_r, sg = array + of_sec = (of_r * 86400).to_i + # Reconstruct local JD and time from AJD + local_r = ajd + 0.5r + of_r + jd = local_r.floor + rem_r = (local_r - jd) * 86400 + h = rem_r.to_i / 3600 + rem_r -= h * 3600 + m = rem_r.to_i / 60 + s_r = rem_r - m * 60 + s_i, s_f = _split_second(s_r) + _init_datetime(jd, h, m, s_i, s_f, of_sec, sg) + when 6 + _nth, jd, df, sf, of, sg = array + h = df / 3600 + df -= h * 3600 + m = df / 60 + s = df % 60 + sf_r = sf.is_a?(Rational) ? (sf / 1_000_000_000) : Rational(sf.to_i, 1_000_000_000) + _init_datetime(jd, h, m, s, sf_r, of, sg) + else + raise TypeError, "invalid marshal data" + end + end + + # --------------------------------------------------------------------------- + # Type conversions + # --------------------------------------------------------------------------- + + # call-seq: + # dt.to_date -> date + # + # Returns a Date object which denotes self. + def to_date + Date.__send__(:new_from_jd, @jd, @sg) + end + + # call-seq: + # dt.to_datetime -> self + # + # Returns self. + def to_datetime + self + end + + # call-seq: + # dt.to_time -> time + # + # Returns a Time object which denotes self. + def to_time + y, m, d = self.class.__send__(:jd_to_gregorian, @jd) + if @of == 0 + Time.utc(y, m, d, @hour, @min, @sec_i + @sec_frac) + else + Time.new(y, m, d, @hour, @min, @sec_i + @sec_frac, @of) + end + end + + # --------------------------------------------------------------------------- + # Class methods + # --------------------------------------------------------------------------- + + class << self + def new(year = -4712, month = 1, day = 1, hour = 0, minute = 0, second = 0, offset = 0, start = Date::ITALY) + instance = allocate + instance.__send__(:initialize, year, month, day, hour, minute, second, offset, start) + instance + end + alias_method :civil, :new + + undef_method :today + + # call-seq: + # DateTime._strptime(string[, format='%FT%T%z']) -> hash + # + # Parses the given representation of date and time with the given + # template, and returns a hash of parsed elements. _strptime does + # not support specification of flags and width unlike strftime. + # + # See also strptime(3) and #strftime. + def _strptime(string = JULIAN_EPOCH_DATETIME, format = '%FT%T%z') + Date._strptime(string, format) + end + + # call-seq: + # DateTime.strptime([string='-4712-01-01T00:00:00+00:00'[, format='%FT%T%z'[ ,start=Date::ITALY]]]) -> datetime + # + # Parses the given representation of date and time with the given + # template, and creates a DateTime object. strptime does not support + # specification of flags and width unlike strftime. + # + # DateTime.strptime('2001-02-03T04:05:06+07:00', '%Y-%m-%dT%H:%M:%S%z') + # #=> # + # DateTime.strptime('03-02-2001 04:05:06 PM', '%d-%m-%Y %I:%M:%S %p') + # #=> # + # DateTime.strptime('2001-W05-6T04:05:06+07:00', '%G-W%V-%uT%H:%M:%S%z') + # #=> # + # DateTime.strptime('2001 04 6 04 05 06 +7', '%Y %U %w %H %M %S %z') + # #=> # + # DateTime.strptime('2001 05 6 04 05 06 +7', '%Y %W %u %H %M %S %z') + # #=> # + # DateTime.strptime('-1', '%s') + # #=> # + # DateTime.strptime('-1000', '%Q') + # #=> # + # DateTime.strptime('sat3feb014pm+7', '%a%d%b%y%H%p%z') + # #=> # + # + # See also strptime(3) and #strftime. + def strptime(string = JULIAN_EPOCH_DATETIME, format = '%FT%T%z', start = Date::ITALY) + hash = _strptime(string, format) + _dt_new_by_frags(hash, start) + end + + # call-seq: + # DateTime.jd([jd=0[, hour=0[, minute=0[, second=0[, offset=0[, start=Date::ITALY]]]]]]) -> datetime + # + # Creates a DateTime object denoting the given chronological Julian + # day number. + # + # DateTime.jd(2451944) #=> # + # DateTime.jd(2451945) #=> # + # DateTime.jd(Rational('0.5')) + # #=> # + def jd(jd = 0, hour = 0, minute = 0, second = 0, offset = 0, start = Date::ITALY) + raise TypeError, "no implicit conversion of #{jd.class} into Integer" unless jd.is_a?(Numeric) + jd_r = jd.to_r + jd_i = jd_r.floor + h = Integer(hour) + m = Integer(minute) + of_sec = _parse_of(offset) + if jd_i != jd_r + # Fractional JD: convert fraction to extra seconds and handle overflow + frac_sec = (jd_r - jd_i) * 86400 + second = second.to_r + frac_sec + sec_i, sec_f = _split_sec(second) + if sec_i >= 60 + carry_m, sec_i = sec_i.divmod(60) + m += carry_m + end + if m >= 60 + carry_h, m = m.divmod(60) + h += carry_h + end + if h >= 24 + carry_d, h = h.divmod(24) + jd_i += carry_d + end + else + # Integer JD: pass raw values to _normalize_hms (non-cascading) + sec_i, sec_f = _split_sec(second) + end + _new_dt_from_jd_time(jd_i, h, m, sec_i, sec_f, of_sec, start) + end + + # call-seq: + # DateTime.ordinal([year=-4712[, yday=1[, hour=0[, minute=0[, second=0[, offset=0[, start=Date::ITALY]]]]]]]) -> datetime + # + # Creates a DateTime object denoting the given ordinal date. + # + # DateTime.ordinal(2001,34) #=> # + # DateTime.ordinal(2001,34,4,5,6,'+7') + # #=> # + # DateTime.ordinal(2001,-332,-20,-55,-54,'+7') + # #=> # + def ordinal(year = -4712, yday = 1, hour = 0, minute = 0, second = 0, offset = 0, start = Date::ITALY) + jd_v = internal_valid_ordinal?(Integer(year), Integer(yday), start) + raise Date::Error, "invalid date" unless jd_v + of_sec = _parse_of(offset) + sec_i, sec_f = _split_sec(second) + _new_dt_from_jd_time(jd_v, Integer(hour), Integer(minute), sec_i, sec_f, of_sec, start) + end + + # call-seq: + # DateTime.commercial([cwyear=-4712[, cweek=1[, cwday=1[, hour=0[, minute=0[, second=0[, offset=0[, start=Date::ITALY]]]]]]]]) -> datetime + # + # Creates a DateTime object denoting the given week date. + # + # DateTime.commercial(2001) #=> # + # DateTime.commercial(2002) #=> # + # DateTime.commercial(2001,5,6,4,5,6,'+7') + # #=> # + def commercial(cwyear = -4712, cweek = 1, cwday = 1, hour = 0, minute = 0, second = 0, offset = 0, start = Date::ITALY) + jd_v = internal_valid_commercial?(Integer(cwyear), Integer(cweek), Integer(cwday), start) + raise Date::Error, "invalid date" unless jd_v + of_sec = _parse_of(offset) + sec_i, sec_f = _split_sec(second) + _new_dt_from_jd_time(jd_v, Integer(hour), Integer(minute), sec_i, sec_f, of_sec, start) + end + + # call-seq: + # DateTime.now([start=Date::ITALY]) -> datetime + # + # Creates a DateTime object denoting the present time. + # + # DateTime.now #=> # + def now(start = Date::ITALY) + t = Time.now + jd = civil_to_jd(t.year, t.mon, t.mday, start) + sec_f = Rational(t.subsec) + _new_dt_from_jd_time(jd, t.hour, t.min, t.sec, sec_f, t.utc_offset, start) + end + + # call-seq: + # DateTime.weeknum(year=-4712, week=0, wday=1, wstart=0, hour=0, min=0, sec=0, offset=0, start=Date::ITALY) -> datetime + def weeknum(year = -4712, week = 0, wday = 1, wstart = 0, + hour = 0, minute = 0, second = 0, offset = 0, start = Date::ITALY) + jd = weeknum_to_jd(Integer(year), Integer(week), Integer(wday), Integer(wstart), start) + of_sec = _parse_of(offset) + sec_i, sec_f = _split_sec(second) + _new_dt_from_jd_time(jd, Integer(hour), Integer(minute), sec_i, sec_f, of_sec, start) + end + + # call-seq: + # DateTime.nth_kday(year=-4712, month=1, n=1, k=1, hour=0, min=0, sec=0, offset=0, start=Date::ITALY) -> datetime + def nth_kday(year = -4712, month = 1, n = 1, k = 1, + hour = 0, minute = 0, second = 0, offset = 0, start = Date::ITALY) + jd = nth_kday_to_jd(Integer(year), Integer(month), Integer(n), Integer(k), start) + of_sec = _parse_of(offset) + sec_i, sec_f = _split_sec(second) + _new_dt_from_jd_time(jd, Integer(hour), Integer(minute), sec_i, sec_f, of_sec, start) + end + + # :nodoc: + def _new_dt_from_jd_time(jd, h, m, s, sf, of, sg) + jd, h, m, s = _normalize_hms(jd, h, m, s) + obj = allocate + obj.__send__(:_init_datetime, jd, h, m, s, sf, of, sg) + obj + end + + # Normalize hour/min/sec. + # Negative values: add one period (non-cascading, matching C's c_valid_time_f?). + # After normalization, validate ranges and raise Date::Error if out of range. + # 24:00:00 is valid (normalizes to next day 00:00:00). + def _normalize_hms(jd, h, m, s) + s += 60 if s < 0 + m += 60 if m < 0 + h += 24 if h < 0 + raise Date::Error, "invalid date" if s >= 60 + raise Date::Error, "invalid date" if m >= 60 + raise Date::Error, "invalid date" if h > 24 || h < 0 + raise Date::Error, "invalid date" if h == 24 && (m != 0 || s != 0) + if h == 24 + jd += 1 + h = 0 + end + [jd, h, m, s] + end + + # call-seq: + # DateTime.parse(string='-4712-01-01T00:00:00+00:00'[, comp=true[, start=Date::ITALY]], limit: 128) -> datetime + # + # Parses the given representation of date and time, and creates a + # DateTime object. + # + # This method *does* *not* function as a validator. If the input + # string does not match valid formats strictly, you may get a cryptic + # result. Should consider to use DateTime.strptime instead of this + # method as possible. + # + # If the optional second argument is true and the detected year is in + # the range "00" to "99", makes it full. + # + # DateTime.parse('2001-02-03T04:05:06+07:00') + # #=> # + # DateTime.parse('20010203T040506+0700') + # #=> # + # DateTime.parse('3rd Feb 2001 04:05:06 PM') + # #=> # + # + # Raise an ArgumentError when the string length is longer than _limit_. + # You can stop this check by passing limit: nil, but note + # that it may take a long time to parse. + def parse(string = '-4712-01-01T00:00:00+00:00', comp = true, start = Date::ITALY, limit: 128) + hash = Date._parse(string, comp, limit: limit) + _dt_new_by_frags(hash, start) + end + + # call-seq: + # DateTime.iso8601(string='-4712-01-01T00:00:00+00:00'[, start=Date::ITALY], limit: 128) -> datetime + # + # Creates a new DateTime object by parsing from a string according to + # some typical ISO 8601 formats. + # + # DateTime.iso8601('2001-02-03T04:05:06+07:00') + # #=> # + # DateTime.iso8601('20010203T040506+0700') + # #=> # + # DateTime.iso8601('2001-W05-6T04:05:06+07:00') + # #=> # + # + # Raise an ArgumentError when the string length is longer than _limit_. + # You can stop this check by passing limit: nil, but note + # that it may take a long time to parse. + def iso8601(string = '-4712-01-01T00:00:00+00:00', start = Date::ITALY, limit: 128) + hash = Date._iso8601(string, limit: limit) + _dt_new_by_frags(hash, start) + end + + # call-seq: + # DateTime.rfc3339(string='-4712-01-01T00:00:00+00:00'[, start=Date::ITALY], limit: 128) -> datetime + # + # Creates a new DateTime object by parsing from a string according to + # some typical RFC 3339 formats. + # + # DateTime.rfc3339('2001-02-03T04:05:06+07:00') + # #=> # + # + # Raise an ArgumentError when the string length is longer than _limit_. + # You can stop this check by passing limit: nil, but note + # that it may take a long time to parse. + def rfc3339(string = '-4712-01-01T00:00:00+00:00', start = Date::ITALY, limit: 128) + hash = Date._rfc3339(string, limit: limit) + _dt_new_by_frags(hash, start) + end + + # call-seq: + # DateTime.xmlschema(string='-4712-01-01T00:00:00+00:00'[, start=Date::ITALY], limit: 128) -> datetime + # + # Creates a new DateTime object by parsing from a string according to + # some typical XML Schema formats. + # + # DateTime.xmlschema('2001-02-03T04:05:06+07:00') + # #=> # + # + # Raise an ArgumentError when the string length is longer than _limit_. + # You can stop this check by passing limit: nil, but note + # that it may take a long time to parse. + def xmlschema(string = '-4712-01-01T00:00:00+00:00', start = Date::ITALY, limit: 128) + hash = Date._xmlschema(string, limit: limit) + _dt_new_by_frags(hash, start) + end + + # call-seq: + # DateTime.rfc2822(string='Mon, 1 Jan -4712 00:00:00 +0000'[, start=Date::ITALY], limit: 128) -> datetime + # DateTime.rfc822(string='Mon, 1 Jan -4712 00:00:00 +0000'[, start=Date::ITALY], limit: 128) -> datetime + # + # Creates a new DateTime object by parsing from a string according to + # some typical RFC 2822 formats. + # + # DateTime.rfc2822('Sat, 3 Feb 2001 04:05:06 +0700') + # #=> # + # + # Raise an ArgumentError when the string length is longer than _limit_. + # You can stop this check by passing limit: nil, but note + # that it may take a long time to parse. + def rfc2822(string = 'Mon, 1 Jan -4712 00:00:00 +0000', start = Date::ITALY, limit: 128) + hash = Date._rfc2822(string, limit: limit) + _dt_new_by_frags(hash, start) + end + alias rfc822 rfc2822 + + # call-seq: + # DateTime.httpdate(string='Mon, 01 Jan -4712 00:00:00 GMT'[, start=Date::ITALY]) -> datetime + # + # Creates a new DateTime object by parsing from a string according to + # some RFC 2616 format. + # + # DateTime.httpdate('Sat, 03 Feb 2001 04:05:06 GMT') + # #=> # + # + # Raise an ArgumentError when the string length is longer than _limit_. + # You can stop this check by passing limit: nil, but note + # that it may take a long time to parse. + def httpdate(string = 'Mon, 01 Jan -4712 00:00:00 GMT', start = Date::ITALY, limit: 128) + hash = Date._httpdate(string, limit: limit) + _dt_new_by_frags(hash, start) + end + + # call-seq: + # DateTime.jisx0301(string='-4712-01-01T00:00:00+00:00'[, start=Date::ITALY], limit: 128) -> datetime + # + # Creates a new DateTime object by parsing from a string according to + # some typical JIS X 0301 formats. + # + # DateTime.jisx0301('H13.02.03T04:05:06+07:00') + # #=> # + # + # For no-era year, legacy format, Heisei is assumed. + # + # DateTime.jisx0301('13.02.03T04:05:06+07:00') + # #=> # + # + # Raise an ArgumentError when the string length is longer than _limit_. + # You can stop this check by passing limit: nil, but note + # that it may take a long time to parse. + def jisx0301(string = '-4712-01-01T00:00:00+00:00', start = Date::ITALY, limit: 128) + hash = Date._jisx0301(string, limit: limit) + _dt_new_by_frags(hash, start) + end + + private + + # Create a DateTime object from parsed fragment hash. + # Uses the same fragment rewrite/complete/validate logic as Date._new_by_frags + # but additionally extracts time components (hour, min, sec, offset, sec_fraction). + def _dt_new_by_frags(hash, sg) + raise Date::Error, 'invalid date' if hash.nil? + hash = sp_rewrite_frags(hash) + orig_sec = hash[:sec] + hash = sp_complete_frags(DateTime, hash) + jd = sp_valid_date_frags_p(hash, sg) + raise Date::Error, 'invalid date' if jd.nil? + + h = hash[:hour] || 0 + m = hash[:min] || 0 + s = hash[:sec] || 0 + raise Date::Error, 'invalid date' if orig_sec && orig_sec > 60 + s = 59 if s > 59 + of = hash[:offset] || 0 + if of.is_a?(Numeric) && (of < -86400 || of > 86400) + warn("invalid offset is ignored: #{of}", uplevel: 0) + of = 0 + end + sf = hash[:sec_fraction] || 0r + _new_dt_from_jd_time(jd, h, m, s, sf, of, sg) + end + + def _parse_of(offset) + case offset + when String + Date.__send__(:offset_str_to_sec, offset) + when Rational + (offset * 86400).to_i + when Numeric + (offset * 86400).to_i + else + 0 + end + end + + def _split_sec(second) + if second.is_a?(Rational) || second.is_a?(Float) + s_r = second.to_r + s_i = s_r.floor + [s_i, s_r - s_i] + else + [Integer(second), 0r] + end + end + + end + + # --------------------------------------------------------------------------- + # Private helpers (strftime overrides) + # --------------------------------------------------------------------------- + + private + + def internal_hour + @hour + end + + def internal_min + @min + end + + def internal_sec + @sec_i + end + + def sec_frac + @sec_frac + end + + def of_seconds + @of + end + + def zone_str + _of2str(@of) + end + + def _init_datetime(jd, h, m, s, sf, of, sg) + @jd = jd + @sg = sg + @hour = h + @min = m + @sec_i = s + @sec_frac = sf.is_a?(Rational) ? sf : Rational(sf) + @of = of.to_i + end + + def _split_second(second) + if second.is_a?(Rational) || second.is_a?(Float) + s_r = second.to_r + s_i = s_r.floor + [s_i, s_r - s_i] + else + [Integer(second), 0r] + end + end + + def _str_offset_to_sec(offset) + case offset + when String + self.class.__send__(:_parse_of, offset) + when Rational + (offset * 86400).to_i + when Numeric + r = offset.to_r + raise TypeError, "#{offset.class} can't be used as offset" unless r.is_a?(Rational) + (r * 86400).to_i + else + 0 + end + end + + def _of2str(of) + sign = of < 0 ? '-' : '+' + abs = of.abs + h = abs / 3600 + m = (abs % 3600) / 60 + format('%s%02d:%02d'.encode(Encoding::US_ASCII), sign, h, m) + end + + def _from_total_sec_r(total_r) + jd = (total_r / 86400).floor + rem = total_r - jd * 86400 + h = rem.to_i / 3600 + rem -= h * 3600 + m = rem.to_i / 60 + s_r = rem - m * 60 + s_i = s_r.floor + s_f = s_r - s_i + self.class.__send__(:_new_dt_from_jd_time,jd, h, m, s_i, s_f, @of, @sg) + end + +end diff --git a/lib/date/parse.rb b/lib/date/parse.rb new file mode 100644 index 00000000..7f2ffaa0 --- /dev/null +++ b/lib/date/parse.rb @@ -0,0 +1,1523 @@ +# frozen_string_literal: true + +require_relative "zonetab" + +class Date + class << self + + # ------------------------------------------------------------------ + # _rfc3339 + # ------------------------------------------------------------------ + + # call-seq: + # Date._rfc3339(string, limit: 128) -> hash + # + # Returns a hash of values parsed from +string+, which should be a valid + # {RFC 3339 format}[rdoc-ref:language/strftime_formatting.rdoc@RFC+3339+Format]: + # + # d = Date.new(2001, 2, 3) + # s = d.rfc3339 # => "2001-02-03T00:00:00+00:00" + # Date._rfc3339(s) + # # => {:year=>2001, :mon=>2, :mday=>3, :hour=>0, :min=>0, :sec=>0, :zone=>"+00:00", :offset=>0} + # + # See argument {limit}[rdoc-ref:Date@Argument+limit]. + # + # Related: Date.rfc3339 (returns a \Date object). + def _rfc3339(string, limit: 128) + unless String === string + raise TypeError if string.is_a?(Symbol) + return {} if string.nil? + string = string.to_str + end + return {} if string.empty? + raise ArgumentError, "string length (#{string.length}) exceeds the limit #{limit}" if limit && string.length > limit + + # Fast path: YYYY-MM-DDTHH:MM:SS+HH:MM (25 bytes) or YYYY-MM-DDTHH:MM:SSZ (20 bytes) + len = string.length + if len == 25 || len == 20 + sc = StringScanner.new(string) + if sc.scan(/(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):(\d{2}):(\d{2})/) + h = { + year: sc[1].to_i, mon: sc[2].to_i, mday: sc[3].to_i, + hour: sc[4].to_i, min: sc[5].to_i, sec: sc[6].to_i + } + if sc.scan(/[Zz]\z/) + h[:zone] = sc.matched + h[:offset] = 0 + return h + elsif sc.scan(/([+-])(\d{2}):(\d{2})\z/) + zone = sc.matched + h[:zone] = zone + h[:offset] = (sc[1] == '-' ? -1 : 1) * (sc[2].to_i * 3600 + sc[3].to_i * 60) + return h + end + end + end + + h = {} + if (m = RFC3339_RE.match(string)) + h[:year] = m[1].to_i + h[:mon] = m[2].to_i + h[:mday] = m[3].to_i + h[:hour] = m[4].to_i + h[:min] = m[5].to_i + h[:sec] = m[6].to_i + h[:sec_fraction] = Rational(m[7].to_i, 10 ** m[7].length) if m[7] + zone = m[8] + h[:zone] = zone + if zone[0] == 'Z' || zone[0] == 'z' + h[:offset] = 0 + else + h[:offset] = (zone[0] == '-' ? -1 : 1) * (zone[1, 2].to_i * 3600 + zone[4, 2].to_i * 60) + end + end + h + end + + # ------------------------------------------------------------------ + # _httpdate + # ------------------------------------------------------------------ + + # call-seq: + # Date._httpdate(string, limit: 128) -> hash + # + # Returns a hash of values parsed from +string+, which should be a valid + # {HTTP date format}[rdoc-ref:language/strftime_formatting.rdoc@HTTP+Format]: + # + # d = Date.new(2001, 2, 3) + # s = d.httpdate # => "Sat, 03 Feb 2001 00:00:00 GMT" + # Date._httpdate(s) + # # => {:wday=>6, :mday=>3, :mon=>2, :year=>2001, :hour=>0, :min=>0, :sec=>0, :zone=>"GMT", :offset=>0} + # + # Related: Date.httpdate (returns a \Date object). + def _httpdate(string, limit: 128) + unless String === string + raise TypeError if string.is_a?(Symbol) + return {} if string.nil? + string = string.to_str + end + return {} if string.empty? + raise ArgumentError, "string length (#{string.length}) exceeds the limit #{limit}" if limit && string.length > limit + + # Fast path for Type 1: "Dow, DD Mon YYYY HH:MM:SS GMT" (29 bytes) + len = string.length + if len == 29 + sc = StringScanner.new(string) + if sc.scan(/([A-Za-z]{3}), (\d{2}) ([A-Za-z]{3}) (\d{4}) (\d{2}):(\d{2}):(\d{2}) (GMT)\z/i) + wkey = compute_3key(sc[1]) + wday_info = ABBR_DAY_3KEY[wkey] + if wday_info + mkey = compute_3key(sc[3]) + mon_info = ABBR_MONTH_3KEY[mkey] + if mon_info + return { + wday: wday_info[0], mday: sc[2].to_i, mon: mon_info[0], + year: sc[4].to_i, hour: sc[5].to_i, min: sc[6].to_i, sec: sc[7].to_i, + zone: 'GMT', offset: 0 + } + end + end + end + end + + h = {} + if (m = HTTPDATE_TYPE1_RE.match(string)) + h[:wday] = HTTPDATE_WDAY[m[1].downcase] + h[:mday] = m[2].to_i + h[:mon] = ABBR_MONTH_NUM[m[3].downcase] + h[:year] = m[4].to_i + h[:hour] = m[5].to_i + h[:min] = m[6].to_i + h[:sec] = m[7].to_i + h[:zone] = m[8] + h[:offset] = 0 + elsif (m = HTTPDATE_TYPE2_RE.match(string)) + h[:wday] = HTTPDATE_FULL_WDAY[m[1].downcase] + h[:mday] = m[2].to_i + h[:mon] = ABBR_MONTH_NUM[m[3].downcase] + y = m[4].to_i + h[:year] = y >= 69 ? y + 1900 : y + 2000 + h[:hour] = m[5].to_i + h[:min] = m[6].to_i + h[:sec] = m[7].to_i + h[:zone] = m[8] + h[:offset] = 0 + elsif (m = HTTPDATE_TYPE3_RE.match(string)) + h[:wday] = HTTPDATE_WDAY[m[1].downcase] + h[:mon] = ABBR_MONTH_NUM[m[2].downcase] + h[:mday] = m[3].to_i + h[:hour] = m[4].to_i + h[:min] = m[5].to_i + h[:sec] = m[6].to_i + h[:year] = m[7].to_i + end + h + end + + # ------------------------------------------------------------------ + # _rfc2822 + # ------------------------------------------------------------------ + + # call-seq: + # Date._rfc2822(string, limit: 128) -> hash + # + # Returns a hash of values parsed from +string+, which should be a valid + # {RFC 2822 date format}[rdoc-ref:language/strftime_formatting.rdoc@RFC+2822+Format]: + # + # d = Date.new(2001, 2, 3) + # s = d.rfc2822 # => "Sat, 3 Feb 2001 00:00:00 +0000" + # Date._rfc2822(s) + # # => {:wday=>6, :mday=>3, :mon=>2, :year=>2001, :hour=>0, :min=>0, :sec=>0, :zone=>"+0000", :offset=>0} + # + # See argument {limit}[rdoc-ref:Date@Argument+limit]. + # + # Related: Date.rfc2822 (returns a \Date object). + def _rfc2822(string, limit: 128) + unless String === string + raise TypeError if string.is_a?(Symbol) + return {} if string.nil? + string = string.to_str + end + return {} if string.empty? + raise ArgumentError, "string length (#{string.length}) exceeds the limit #{limit}" if limit && string.length > limit + + # Fast path: "Dow, DD Mon YYYY HH:MM:SS +ZZZZ" (31 bytes, 2-digit day) + len = string.length + if len == 31 + sc = StringScanner.new(string) + if sc.scan(/([A-Za-z]{3}), (\d{2}) ([A-Za-z]{3}) (\d{4}) (\d{2}):(\d{2}):(\d{2}) ([+-]\d{4})\z/) + wkey = compute_3key(sc[1]) + wday_info = ABBR_DAY_3KEY[wkey] + if wday_info + mkey = compute_3key(sc[3]) + mon_info = ABBR_MONTH_3KEY[mkey] + if mon_info + zone = sc[8] + sign = zone[0] == '-' ? -1 : 1 + offset_val = sign * (zone[1, 2].to_i * 3600 + zone[3, 2].to_i * 60) + zone = '+0000' if zone == '+0000' + return { + wday: wday_info[0], mday: sc[2].to_i, mon: mon_info[0], + year: sc[4].to_i, hour: sc[5].to_i, min: sc[6].to_i, sec: sc[7].to_i, + zone: zone, offset: offset_val + } + end + end + end + end + + # Preprocess: remove obs-FWS (\r\n and \0) - skip if not needed + s = (string.include?("\r") || string.include?("\n") || string.include?("\0")) ? + string.gsub(/[\r\n\0]+/, ' ') : string + h = {} + if (m = RFC2822_RE.match(s)) + h[:wday] = HTTPDATE_WDAY[m[1].downcase] if m[1] + h[:mday] = m[2].to_i + h[:mon] = ABBR_MONTH_NUM[m[3].downcase] + y_s = m[4] + y = y_s.to_i + ylen = y_s[0] == '-' ? y_s.length - 1 : y_s.length + if ylen < 4 + if ylen == 3 + h[:year] = y + 1900 + else + h[:year] = y >= 50 ? y + 1900 : y + 2000 + end + else + h[:year] = y + end + h[:hour] = m[5].to_i + h[:min] = m[6].to_i + h[:sec] = m[7].to_i if m[7] + h[:zone] = m[8] + h[:offset] = fast_zone_offset(m[8]) + end + h + end + alias _rfc822 _rfc2822 + + # ------------------------------------------------------------------ + # _xmlschema + # ------------------------------------------------------------------ + + # call-seq: + # Date._xmlschema(string, limit: 128) -> hash + # + # Returns a hash of values parsed from +string+, which should be a valid + # XML date format: + # + # d = Date.new(2001, 2, 3) + # s = d.xmlschema # => "2001-02-03" + # Date._xmlschema(s) # => {:year=>2001, :mon=>2, :mday=>3} + # + # See argument {limit}[rdoc-ref:Date@Argument+limit]. + # + # Related: Date.xmlschema (returns a \Date object). + def _xmlschema(string, limit: 128) + unless String === string + raise TypeError if string.is_a?(Symbol) + return {} if string.nil? + string = string.to_str + end + return {} if string.empty? + raise ArgumentError, "string length (#{string.length}) exceeds the limit #{limit}" if limit && string.length > limit + + # Fast path: YYYY-MM-DD (exactly 10 bytes, all ASCII) + if string.length == 10 + sc = StringScanner.new(string) + if sc.scan(/(\d{4})-(\d{2})-(\d{2})\z/) + return { year: sc[1].to_i, mon: sc[2].to_i, mday: sc[3].to_i } + end + end + + h = {} + if (m = XMLSCHEMA_DATETIME_RE.match(string)) + h[:year] = m[1].to_i + h[:mon] = m[2].to_i if m[2] + h[:mday] = m[3].to_i if m[3] + h[:hour] = m[4].to_i if m[4] + h[:min] = m[5].to_i if m[5] + h[:sec] = m[6].to_i if m[6] + h[:sec_fraction] = parse_sec_fraction(m[7]) if m[7] + parse_zone_and_offset(m[8], h) if m[8] + elsif (m = XMLSCHEMA_TIME_RE.match(string)) + h[:hour] = m[1].to_i + h[:min] = m[2].to_i + h[:sec] = m[3].to_i + h[:sec_fraction] = parse_sec_fraction(m[4]) if m[4] + parse_zone_and_offset(m[5], h) if m[5] + elsif (m = XMLSCHEMA_TRUNC_RE.match(string)) + if m[3] + h[:mday] = m[3].to_i + else + h[:mon] = m[1].to_i if m[1] + h[:mday] = m[2].to_i if m[2] + end + parse_zone_and_offset(m[4], h) if m[4] + end + h + end + + # ------------------------------------------------------------------ + # _iso8601 + # ------------------------------------------------------------------ + + # call-seq: + # Date._iso8601(string, limit: 128) -> hash + # + # Returns a hash of values parsed from +string+, which should contain + # an {ISO 8601 formatted date}[rdoc-ref:language/strftime_formatting.rdoc@ISO+8601+Format+Specifications]: + # + # d = Date.new(2001, 2, 3) + # s = d.iso8601 # => "2001-02-03" + # Date._iso8601(s) # => {:mday=>3, :year=>2001, :mon=>2} + # + # See argument {limit}[rdoc-ref:Date@Argument+limit]. + # + # Related: Date.iso8601 (returns a \Date object). + def _iso8601(string, limit: 128) + unless String === string + raise TypeError if string.is_a?(Symbol) + return {} if string.nil? + string = string.to_str + end + return {} if string.empty? + raise ArgumentError, "string length (#{string.length}) exceeds the limit #{limit}" if limit && string.length > limit + + # Fast path: YYYY-MM-DD (exactly 10 bytes, all ASCII) + if string.length == 10 + sc = StringScanner.new(string) + if sc.scan(/(\d{4})-(\d{2})-(\d{2})\z/) + return { mday: sc[3].to_i, year: sc[1].to_i, mon: sc[2].to_i } + end + end + + h = {} + + if (m = ISO8601_EXT_DATETIME_RE.match(string)) + iso8601_ext_datetime(m, h) + elsif (m = ISO8601_BAS_DATETIME_RE.match(string)) + iso8601_bas_datetime(m, h) + elsif (m = ISO8601_EXT_TIME_RE.match(string)) + h[:hour] = m[1].to_i + h[:min] = m[2].to_i + h[:sec] = m[3].to_i if m[3] + h[:sec_fraction] = parse_sec_fraction(m[4]) if m[4] + parse_zone_and_offset(m[5], h) if m[5] + elsif (m = ISO8601_BAS_TIME_RE.match(string)) + h[:hour] = m[1].to_i + h[:min] = m[2].to_i + h[:sec] = m[3].to_i if m[3] + h[:sec_fraction] = parse_sec_fraction(m[4]) if m[4] + parse_zone_and_offset(m[5], h) if m[5] + end + h + end + + # ------------------------------------------------------------------ + # _jisx0301 + # ------------------------------------------------------------------ + + # call-seq: + # Date._jisx0301(string, limit: 128) -> hash + # + # Returns a hash of values parsed from +string+, which should be a valid + # {JIS X 0301 date format}[rdoc-ref:language/strftime_formatting.rdoc@JIS+X+0301+Format]: + # + # d = Date.new(2001, 2, 3) + # s = d.jisx0301 # => "H13.02.03" + # Date._jisx0301(s) # => {:year=>2001, :mon=>2, :mday=>3} + # + # See argument {limit}[rdoc-ref:Date@Argument+limit]. + # + # Related: Date.jisx0301 (returns a \Date object). + def _jisx0301(string, limit: 128) + unless String === string + raise TypeError if string.is_a?(Symbol) + return {} if string.nil? + string = string.to_str + end + return {} if string.empty? + raise ArgumentError, "string length (#{string.length}) exceeds the limit #{limit}" if limit && string.length > limit + + # Fast path: X##.##.## (9 bytes: era + YY.MM.DD) + if string.length == 9 + sc = StringScanner.new(string) + if sc.scan(/([A-Za-z])(\d{2})\.(\d{2})\.(\d{2})\z/) + era_offset = JISX0301_ERA[sc[1].downcase] + if era_offset + return { + year: sc[2].to_i + era_offset, + mon: sc[3].to_i, + mday: sc[4].to_i + } + end + end + end + + h = {} + if (m = JISX0301_RE.match(string)) + era_char = m[1] ? m[1].downcase : 'h' + era_offset = JISX0301_ERA[era_char] + h[:year] = m[2].to_i + era_offset + h[:mon] = m[3].to_i + h[:mday] = m[4].to_i + h[:hour] = m[5].to_i if m[5] + h[:min] = m[6].to_i if m[6] + h[:sec] = m[7].to_i if m[7] + h[:sec_fraction] = parse_sec_fraction(m[8]) if m[8] && !m[8].empty? + parse_zone_and_offset(m[9], h) if m[9] + else + h = _iso8601(string, limit: limit) + end + h + end + + # ------------------------------------------------------------------ + # _parse + # ------------------------------------------------------------------ + + # call-seq: + # Date._parse(string, comp = true, limit: 128) -> hash + # + # Note: + # This method recognizes many forms in +string+, + # but it is not a validator. + # For formats, see + # {"Specialized Format Strings" in Formats for Dates and Times}[rdoc-ref:language/strftime_formatting.rdoc@Specialized+Format+Strings] + # + # If +string+ does not specify a valid date, + # the result is unpredictable; + # consider using Date._strptime instead. + # + # Returns a hash of values parsed from +string+: + # + # Date._parse('2001-02-03') # => {:year=>2001, :mon=>2, :mday=>3} + # + # If +comp+ is +true+ and the given year is in the range (0..99), + # the current century is supplied; + # otherwise, the year is taken as given: + # + # Date._parse('01-02-03', true) # => {:year=>2001, :mon=>2, :mday=>3} + # Date._parse('01-02-03', false) # => {:year=>1, :mon=>2, :mday=>3} + # + # See argument {limit}[rdoc-ref:Date@Argument+limit]. + # + # Related: Date.parse(returns a \Date object). + def _parse(string, comp = true, limit: 128) + unless String === string + raise TypeError, "no implicit conversion of #{string.class} into String" if string.is_a?(Symbol) || string.nil? + string = string.to_str + end + return {} if string.empty? + raise ArgumentError, "string length (#{string.length}) exceeds limit (#{limit})" if limit && string.length > limit + + # === Fast paths for common date formats === + len = string.length + + # Fast ISO: YYYY-MM-DD (exactly 10 ASCII bytes) + if len == 10 + sc = StringScanner.new(string) + if sc.scan(/(\d{4})-(\d{2})-(\d{2})\z/) + return { year: sc[1].to_i, mon: sc[2].to_i, mday: sc[3].to_i } + end + end + + # Fast compact: YYYYMMDD (exactly 8 ASCII digit bytes) + if len == 8 + sc = StringScanner.new(string) + if sc.scan(/(\d{4})(\d{2})(\d{2})\z/) + return { year: sc[1].to_i, mon: sc[2].to_i, mday: sc[3].to_i } + end + end + + # Fast US: "Month DD, YYYY" + if (m = FAST_PARSE_US_RE.match(string)) + mon = ABBR_MONTH_NUM[m[1].downcase] + return {year: m[3].to_i, mon: mon, mday: m[2].to_i} if mon + end + + # Fast EU: "DD Month YYYY" + if (m = FAST_PARSE_EU_RE.match(string)) + mon = ABBR_MONTH_NUM[m[2].downcase] + return {year: m[3].to_i, mon: mon, mday: m[1].to_i} if mon + end + + # Fast RFC2822-like: "Dow, DD Mon YYYY HH:MM:SS +ZZZZ" + if (m = FAST_PARSE_RFC2822_RE.match(string)) + wday = ABBR_DAY_NUM[m[1].downcase] + mon = ABBR_MONTH_NUM[m[3].downcase] + if wday && mon + zone = m[8] + return { + wday: wday, mday: m[2].to_i, mon: mon, year: m[4].to_i, + hour: m[5].to_i, min: m[6].to_i, sec: m[7].to_i, + zone: zone, offset: fast_zone_offset(zone) + } + end + end + + # Preprocessing: replace non-date chars with space + str = string.dup + str.gsub!(/[^-+',.\/:\@\[\][:alnum:]]+/, ' ') + + # check_class (byte-level for speed) + cc = 0 + str.each_byte do |b| + if b >= 65 && b <= 90 || b >= 97 && b <= 122 || b > 127 + cc |= HAVE_ALPHA + elsif b >= 48 && b <= 57 + cc |= HAVE_DIGIT + elsif b == 45 + cc |= HAVE_DASH + elsif b == 46 + cc |= HAVE_DOT + elsif b == 47 + cc |= HAVE_SLASH + elsif b == 58 + cc |= HAVE_COLON + end + end + + h = {} + + # parse_day (always runs) + if (cc & HAVE_ALPHA) != 0 + parse_day(str, h) + end + + # parse_time (needs colon or alpha for h/am/pm patterns) + if (cc & HAVE_DIGIT) != 0 && (cc & (HAVE_COLON | HAVE_ALPHA)) != 0 + parse_time(str, h) + end + + # Date parsers: first match wins (goto ok) + matched = false + + if !matched && (cc & (HAVE_ALPHA | HAVE_DIGIT)) == (HAVE_ALPHA | HAVE_DIGIT) + matched = parse_eu(str, h) + end + + if !matched && (cc & (HAVE_ALPHA | HAVE_DIGIT)) == (HAVE_ALPHA | HAVE_DIGIT) + matched = parse_us(str, h) + end + + if !matched && (cc & (HAVE_DIGIT | HAVE_DASH)) == (HAVE_DIGIT | HAVE_DASH) + matched = parse_iso(str, h) + end + + if !matched && (cc & (HAVE_DIGIT | HAVE_DOT)) == (HAVE_DIGIT | HAVE_DOT) + matched = parse_jis(str, h) + end + + if !matched && (cc & (HAVE_ALPHA | HAVE_DIGIT | HAVE_DASH)) == (HAVE_ALPHA | HAVE_DIGIT | HAVE_DASH) + matched = parse_vms(str, h) + end + + if !matched && (cc & (HAVE_DIGIT | HAVE_SLASH)) == (HAVE_DIGIT | HAVE_SLASH) + matched = parse_sla(str, h) + end + + if !matched && (cc & (HAVE_DIGIT | HAVE_DOT)) == (HAVE_DIGIT | HAVE_DOT) + matched = parse_dot(str, h) + end + + if !matched && (cc & HAVE_DIGIT) != 0 + matched = parse_iso2(str, h) + end + + if !matched && (cc & HAVE_DIGIT) != 0 + matched = parse_year(str, h) + end + + if !matched && (cc & HAVE_ALPHA) != 0 + matched = parse_mon(str, h) + end + + if !matched && (cc & HAVE_DIGIT) != 0 + matched = parse_mday(str, h) + end + + if !matched && (cc & HAVE_DIGIT) != 0 + parse_ddd(str, h) + end + + # Post-processing (always runs after ok label) + # parse_bc + if (cc & HAVE_ALPHA) != 0 + parse_bc_post(str, h) + end + + # parse_frag + if (cc & HAVE_DIGIT) != 0 + parse_frag(str, h) + end + + # BC handling + if h.delete(:_bc) + h[:cwyear] = -h[:cwyear] + 1 if h[:cwyear] + h[:year] = -h[:year] + 1 if h[:year] + end + + # comp (century completion) + if comp && h.delete(:_comp) != false + [:cwyear, :year].each do |key| + y = h[key] + if y && y >= 0 && y <= 99 + h[key] = y >= 69 ? y + 1900 : y + 2000 + end + end + end + + # zone -> offset + if h[:zone] && !h.key?(:offset) + h[:offset] = fast_zone_offset(h[:zone]) + end + + h + end + + # ------------------------------------------------------------------ + # parse constructor + # ------------------------------------------------------------------ + + # call-seq: + # Date.parse(string = '-4712-01-01', comp = true, start = Date::ITALY, limit: 128) -> date + # + # Note: + # This method recognizes many forms in +string+, + # but it is not a validator. + # For formats, see + # {"Specialized Format Strings" in Formats for Dates and Times}[rdoc-ref:language/strftime_formatting.rdoc@Specialized+Format+Strings] + # If +string+ does not specify a valid date, + # the result is unpredictable; + # consider using Date._strptime instead. + # + # Returns a new \Date object with values parsed from +string+: + # + # Date.parse('2001-02-03') # => # + # Date.parse('20010203') # => # + # Date.parse('3rd Feb 2001') # => # + # + # If +comp+ is +true+ and the given year is in the range (0..99), + # the current century is supplied; + # otherwise, the year is taken as given: + # + # Date.parse('01-02-03', true) # => # + # Date.parse('01-02-03', false) # => # + # + # See: + # + # - Argument {start}[rdoc-ref:language/calendars.rdoc@Argument+start]. + # - Argument {limit}[rdoc-ref:Date@Argument+limit]. + # + # Related: Date._parse (returns a hash). + def parse(string = '-4712-01-01', comp = true, start = DEFAULT_SG, limit: 128) + hash = _parse(string, comp, limit: limit) + fast_new_date(hash, start) + end + + # ------------------------------------------------------------------ + # Specialized constructors + # ------------------------------------------------------------------ + + # call-seq: + # Date.iso8601(string = '-4712-01-01', start = Date::ITALY, limit: 128) -> date + # + # Returns a new \Date object with values parsed from +string+, + # which should contain + # an {ISO 8601 formatted date}[rdoc-ref:language/strftime_formatting.rdoc@ISO+8601+Format+Specifications]: + # + # d = Date.new(2001, 2, 3) + # s = d.iso8601 # => "2001-02-03" + # Date.iso8601(s) # => # + # + # See: + # + # - Argument {start}[rdoc-ref:language/calendars.rdoc@Argument+start]. + # - Argument {limit}[rdoc-ref:Date@Argument+limit]. + # + # Related: Date._iso8601 (returns a hash). + def iso8601(string = JULIAN_EPOCH_DATE, start = DEFAULT_SG, limit: 128) + hash = _iso8601(string, limit: limit) + fast_new_date(hash, start) + end + + # call-seq: + # Date.rfc3339(string = '-4712-01-01T00:00:00+00:00', start = Date::ITALY, limit: 128) -> date + # + # Returns a new \Date object with values parsed from +string+, + # which should be a valid + # {RFC 3339 format}[rdoc-ref:language/strftime_formatting.rdoc@RFC+3339+Format]: + # + # d = Date.new(2001, 2, 3) + # s = d.rfc3339 # => "2001-02-03T00:00:00+00:00" + # Date.rfc3339(s) # => # + # + # See: + # + # - Argument {start}[rdoc-ref:language/calendars.rdoc@Argument+start]. + # - Argument {limit}[rdoc-ref:Date@Argument+limit]. + # + # Related: Date._rfc3339 (returns a hash). + def rfc3339(string = JULIAN_EPOCH_DATETIME, start = DEFAULT_SG, limit: 128) + hash = _rfc3339(string, limit: limit) + fast_new_date(hash, start) + end + + # call-seq: + # Date.xmlschema(string = '-4712-01-01', start = Date::ITALY, limit: 128) -> date + # + # Returns a new \Date object with values parsed from +string+, + # which should be a valid XML date format: + # + # d = Date.new(2001, 2, 3) + # s = d.xmlschema # => "2001-02-03" + # Date.xmlschema(s) # => # + # + # See: + # + # - Argument {start}[rdoc-ref:language/calendars.rdoc@Argument+start]. + # - Argument {limit}[rdoc-ref:Date@Argument+limit]. + # + # Related: Date._xmlschema (returns a hash). + def xmlschema(string = JULIAN_EPOCH_DATE, start = DEFAULT_SG, limit: 128) + hash = _xmlschema(string, limit: limit) + fast_new_date(hash, start) + end + + # call-seq: + # Date.rfc2822(string = 'Mon, 1 Jan -4712 00:00:00 +0000', start = Date::ITALY, limit: 128) -> date + # + # Returns a new \Date object with values parsed from +string+, + # which should be a valid + # {RFC 2822 date format}[rdoc-ref:language/strftime_formatting.rdoc@RFC+2822+Format]: + # + # d = Date.new(2001, 2, 3) + # s = d.rfc2822 # => "Sat, 3 Feb 2001 00:00:00 +0000" + # Date.rfc2822(s) # => # + # + # See: + # + # - Argument {start}[rdoc-ref:language/calendars.rdoc@Argument+start]. + # - Argument {limit}[rdoc-ref:Date@Argument+limit]. + # + # Related: Date._rfc2822 (returns a hash). + def rfc2822(string = JULIAN_EPOCH_DATETIME_RFC2822, start = DEFAULT_SG, limit: 128) + hash = _rfc2822(string, limit: limit) + fast_new_date(hash, start) + end + alias rfc822 rfc2822 + + # call-seq: + # Date.httpdate(string = 'Mon, 01 Jan -4712 00:00:00 GMT', start = Date::ITALY, limit: 128) -> date + # + # Returns a new \Date object with values parsed from +string+, + # which should be a valid + # {HTTP date format}[rdoc-ref:language/strftime_formatting.rdoc@HTTP+Format]: + # + # d = Date.new(2001, 2, 3) + # s = d.httpdate # => "Sat, 03 Feb 2001 00:00:00 GMT" + # Date.httpdate(s) # => # + # + # See: + # + # - Argument {start}[rdoc-ref:language/calendars.rdoc@Argument+start]. + # - Argument {limit}[rdoc-ref:Date@Argument+limit]. + # + # Related: Date._httpdate (returns a hash). + def httpdate(string = JULIAN_EPOCH_DATETIME_HTTPDATE, start = DEFAULT_SG, limit: 128) + hash = _httpdate(string, limit: limit) + fast_new_date(hash, start) + end + + # call-seq: + # Date.jisx0301(string = '-4712-01-01', start = Date::ITALY, limit: 128) -> date + # + # Returns a new \Date object with values parsed from +string+, + # which should be a valid {JIS X 0301 format}[rdoc-ref:language/strftime_formatting.rdoc@JIS+X+0301+Format]: + # + # d = Date.new(2001, 2, 3) + # s = d.jisx0301 # => "H13.02.03" + # Date.jisx0301(s) # => # + # + # For no-era year, legacy format, Heisei is assumed. + # + # Date.jisx0301('13.02.03') # => # + # + # See: + # + # - Argument {start}[rdoc-ref:language/calendars.rdoc@Argument+start]. + # - Argument {limit}[rdoc-ref:Date@Argument+limit]. + # + # Related: Date._jisx0301 (returns a hash). + def jisx0301(string = JULIAN_EPOCH_DATE, start = DEFAULT_SG, limit: 128) + hash = _jisx0301(string, limit: limit) + fast_new_date(hash, start) + end + + private + + # ------------------------------------------------------------------ + # Shared infrastructure + # ------------------------------------------------------------------ + + def parse_check_limit(str, limit) + raise ArgumentError, "string length (#{str.length}) exceeds limit (#{limit})" if limit && str.length > limit + end + + def parse_to_str(obj) + return nil if obj.nil? + raise TypeError, "no implicit conversion of #{obj.class} into String" if obj.is_a?(Symbol) + String === obj ? obj : obj.to_str + end + + def parse_zone_and_offset(zone_str, hash) + return unless zone_str + hash[:zone] = zone_str + hash[:offset] = fast_zone_offset(zone_str) + end + + def parse_sec_fraction(frac_str) + Rational(frac_str.to_i, 10 ** frac_str.length) + end + + # Fast zone offset calculation for common patterns. + # Handles: Z/z, +HH:MM/-HH:MM, +HHMM/-HHMM, short named zones. + # Falls back to sp_zone_to_diff for complex cases. + def fast_zone_offset(zone_str) + len = zone_str.length + + # Z/z + return 0 if len == 1 && (zone_str[0] == 'Z' || zone_str[0] == 'z') + + if zone_str[0] == '+' || zone_str[0] == '-' + sc = StringScanner.new(zone_str) + if sc.scan(/([+-])(\d{2}):?(\d{2})\z/) + sign = sc[1] == '-' ? -1 : 1 + return sign * (sc[2].to_i * 3600 + sc[3].to_i * 60) + end + end + + # Short named zones: gmt, utc, est, etc. + if len <= 3 + off = ZONE_TABLE[zone_str.downcase] + return off if off + end + + # Fall back to full parser + sp_zone_to_diff(zone_str) + end + + # ------------------------------------------------------------------ + # _iso8601 helpers + # ------------------------------------------------------------------ + + def comp_year69(y) + y >= 69 ? y + 1900 : y + 2000 + end + + def iso8601_ext_datetime(m, h) + if m[1] + # year-mon-mday or truncated + unless m[1] == '-' + y = m[1].to_i + h[:year] = (m[1].length <= 2 && !m[1].start_with?('+') && !m[1].start_with?('-')) ? comp_year69(y) : y + end + h[:mon] = m[2].to_i if m[2] + h[:mday] = m[3].to_i if m[3] + elsif m[4] || m[5] + # year-yday + if m[4] + y = m[4].to_i + h[:year] = (m[4].length <= 2 && !m[4].start_with?('+') && !m[4].start_with?('-')) ? comp_year69(y) : y + end + h[:yday] = m[5].to_i + elsif m[6] || m[7] + # cwyear-wNN-D + if m[6] + y = m[6].to_i + h[:cwyear] = m[6].length <= 2 ? comp_year69(y) : y + end + h[:cweek] = m[7].to_i + h[:cwday] = m[8].to_i if m[8] + elsif m[9] + # -w-D + h[:cwday] = m[9].to_i + end + # time part + if m[10] + h[:hour] = m[10].to_i + h[:min] = m[11].to_i if m[11] + h[:sec] = m[12].to_i if m[12] + h[:sec_fraction] = parse_sec_fraction(m[13]) if m[13] + parse_zone_and_offset(m[14], h) if m[14] + end + end + + def iso8601_bas_datetime(m, h) + if m[1] + # yyyymmdd / --mmdd / ----dd + unless m[1] == '--' + y_s = m[1] + y = y_s.to_i + ylen = y_s.sub(/\A[-+]/, '').length + h[:year] = (ylen <= 2 && !y_s.start_with?('+') && !y_s.start_with?('-')) ? comp_year69(y) : y + end + h[:mon] = m[2].to_i unless m[2] == '-' + h[:mday] = m[3].to_i + elsif m[4] + # yyyyddd + y_s = m[4] + y = y_s.to_i + ylen = y_s.sub(/\A[-+]/, '').length + h[:year] = (ylen <= 2 && !y_s.start_with?('+') && !y_s.start_with?('-')) ? comp_year69(y) : y + h[:yday] = m[5].to_i + elsif m[6] + # -ddd + h[:yday] = m[6].to_i + elsif m[7] + # yyyywwwd + y = m[7].to_i + h[:cwyear] = m[7].length <= 2 ? comp_year69(y) : y + h[:cweek] = m[8].to_i + h[:cwday] = m[9].to_i + elsif m[10] + # -wNN-D + h[:cweek] = m[10].to_i + h[:cwday] = m[11].to_i + elsif m[12] + # -w-D + h[:cwday] = m[12].to_i + end + # time part + if m[13] + h[:hour] = m[13].to_i + h[:min] = m[14].to_i if m[14] + h[:sec] = m[15].to_i if m[15] + h[:sec_fraction] = parse_sec_fraction(m[16]) if m[16] + parse_zone_and_offset(m[17], h) if m[17] + end + end + + # ------------------------------------------------------------------ + # _parse sub-parsers (private) + # ------------------------------------------------------------------ + + def parse_day(str, h) + if (m = PARSE_DAYS_RE.match(str)) + h[:wday] = ABBR_DAY_NUM[m[1].downcase] + str[m.begin(0)...m.end(0)] = ' ' * (m.end(0) - m.begin(0)) + end + end + + def parse_time(str, h) + if (m = PARSE_TIME_RE.match(str)) + time_part = m[1] + zone_part = m[2] + str[m.begin(0)...m.end(0)] = ' ' * (m.end(0) - m.begin(0)) + + if (tm = PARSE_TIME_CB_RE.match(time_part)) + hour = tm[1].to_i + if tm[5] + ampm = tm[5].downcase + if ampm == 'p' + hour = (hour % 12) + 12 + else + hour = hour % 12 + end + end + h[:hour] = hour + h[:min] = tm[2].to_i if tm[2] + h[:sec] = tm[3].to_i if tm[3] + h[:sec_fraction] = parse_sec_fraction(tm[4]) if tm[4] + end + + if zone_part + h[:zone] = zone_part + end + end + end + + def parse_eu(str, h) + if (m = PARSE_EU_RE.match(str)) + mon = ABBR_MONTH_NUM[m[2].downcase] + return false unless mon + bc = m[3] && m[3] =~ /\Ab/i ? true : false + s3e(h, m[4], mon, m[1], bc) + str[m.begin(0)...m.end(0)] = ' ' * (m.end(0) - m.begin(0)) + true + else + false + end + end + + def parse_us(str, h) + if (m = PARSE_US_RE.match(str)) + mon = ABBR_MONTH_NUM[m[1].downcase] + return false unless mon + bc = m[3] && m[3] =~ /\Ab/i ? true : false + s3e(h, m[4], mon, m[2], bc) + str[m.begin(0)...m.end(0)] = ' ' * (m.end(0) - m.begin(0)) + true + else + false + end + end + + def parse_iso(str, h) + if (m = PARSE_ISO_RE.match(str)) + y_s = m[1] + m_s = m[2] + d_s = m[3] + # Fast path: y is unambiguous year (3+ digits or signed), d is short + if y_s =~ /\A[-+]?\d{3,}\z/ && d_s =~ /\A\d{1,2}\z/ + h[:year] = y_s.to_i + h[:_comp] = false + h[:mon] = m_s.to_i + h[:mday] = d_s.to_i + else + s3e(h, y_s, m_s, d_s, false) + end + str[m.begin(0)...m.end(0)] = ' ' * (m.end(0) - m.begin(0)) + true + else + false + end + end + + def parse_jis(str, h) + if (m = PARSE_JIS_RE.match(str)) + era_char = m[1].downcase + era_offset = JISX0301_ERA[era_char] + return false unless era_offset + h[:year] = m[2].to_i + era_offset + h[:mon] = m[3].to_i + h[:mday] = m[4].to_i + str[m.begin(0)...m.end(0)] = ' ' * (m.end(0) - m.begin(0)) + true + else + false + end + end + + def parse_vms(str, h) + if (m = PARSE_VMS11_RE.match(str)) + mon = ABBR_MONTH_NUM[m[2].downcase] + return false unless mon + s3e(h, m[3], mon, m[1], false) + str[m.begin(0)...m.end(0)] = ' ' * (m.end(0) - m.begin(0)) + true + elsif (m = PARSE_VMS12_RE.match(str)) + mon = ABBR_MONTH_NUM[m[1].downcase] + return false unless mon + s3e(h, m[3], mon, m[2], false) + str[m.begin(0)...m.end(0)] = ' ' * (m.end(0) - m.begin(0)) + true + else + false + end + end + + def parse_sla(str, h) + if (m = PARSE_SLA_RE.match(str)) + s3e(h, m[1], m[2], m[3], false) + str[m.begin(0)...m.end(0)] = ' ' * (m.end(0) - m.begin(0)) + true + else + false + end + end + + def parse_dot(str, h) + if (m = PARSE_DOT_RE.match(str)) + s3e(h, m[1], m[2], m[3], false) + str[m.begin(0)...m.end(0)] = ' ' * (m.end(0) - m.begin(0)) + true + else + false + end + end + + def parse_iso2(str, h) + # iso21: week date + if (m = PARSE_ISO21_RE.match(str)) + if m[1] + y = m[1].to_i + h[:cwyear] = y + end + h[:cweek] = m[2].to_i + h[:cwday] = m[3].to_i if m[3] + str[m.begin(0)...m.end(0)] = ' ' * (m.end(0) - m.begin(0)) + return true + end + + # iso22: -w-D + if (m = PARSE_ISO22_RE.match(str)) + h[:cwday] = m[1].to_i + str[m.begin(0)...m.end(0)] = ' ' * (m.end(0) - m.begin(0)) + return true + end + + # iso23: --MM-DD + if (m = PARSE_ISO23_RE.match(str)) + h[:mon] = m[1].to_i if m[1] + h[:mday] = m[2].to_i + str[m.begin(0)...m.end(0)] = ' ' * (m.end(0) - m.begin(0)) + return true + end + + # iso24: --MMDD + if (m = PARSE_ISO24_RE.match(str)) + h[:mon] = m[1].to_i + h[:mday] = m[2].to_i if m[2] + str[m.begin(0)...m.end(0)] = ' ' * (m.end(0) - m.begin(0)) + return true + end + + # iso25: YYYY-DDD (guard against fraction match) + unless str =~ /[,.]\d{2,4}-\d{3}\b/ + if (m = PARSE_ISO25_RE.match(str)) + h[:year] = m[1].to_i + h[:yday] = m[2].to_i + str[m.begin(0)...m.end(0)] = ' ' * (m.end(0) - m.begin(0)) + return true + end + end + + # iso26: -DDD (guard against digit-DDD) + unless str =~ /\d-\d{3}\b/ + if (m = PARSE_ISO26_RE.match(str)) + h[:yday] = m[1].to_i + str[m.begin(0)...m.end(0)] = ' ' * (m.end(0) - m.begin(0)) + return true + end + end + + false + end + + def parse_year(str, h) + if (m = PARSE_YEAR_RE.match(str)) + h[:year] = m[1].to_i + str[m.begin(0)...m.end(0)] = ' ' * (m.end(0) - m.begin(0)) + true + else + false + end + end + + def parse_mon(str, h) + if (m = PARSE_MON_RE.match(str)) + mon = ABBR_MONTH_NUM[m[1].downcase] + if mon + h[:mon] = mon + str[m.begin(0)...m.end(0)] = ' ' * (m.end(0) - m.begin(0)) + return true + end + end + false + end + + def parse_mday(str, h) + if (m = PARSE_MDAY_RE.match(str)) + h[:mday] = m[1].to_i + str[m.begin(0)...m.end(0)] = ' ' * (m.end(0) - m.begin(0)) + true + else + false + end + end + + def parse_ddd(str, h) + if (m = PARSE_DDD_RE.match(str)) + sign = m[1] + s2 = m[2] + s3 = m[3] + s4 = m[4] + s5 = m[5] + + l2 = s2.length + case l2 + when 2 + if s3.nil? && s4 + h[:sec] = s2.to_i + else + h[:mday] = s2.to_i + end + when 4 + if s3.nil? && s4 + h[:sec] = s2[2, 2].to_i + h[:min] = s2[0, 2].to_i + else + h[:mon] = s2[0, 2].to_i + h[:mday] = s2[2, 2].to_i + end + when 6 + if s3.nil? && s4 + h[:sec] = s2[4, 2].to_i + h[:min] = s2[2, 2].to_i + h[:hour] = s2[0, 2].to_i + else + h[:year] = (sign.to_s + s2[0, 2]).to_i + h[:mon] = s2[2, 2].to_i + h[:mday] = s2[4, 2].to_i + end + when 8, 10, 12, 14 + if s3.nil? && s4 + # read from end: sec,min,hour,mday,mon,year + pos = l2 + h[:sec] = s2[pos - 2, 2].to_i + pos -= 2 + h[:min] = s2[pos - 2, 2].to_i + pos -= 2 + h[:hour] = s2[pos - 2, 2].to_i + pos -= 2 + if pos >= 2 + h[:mday] = s2[pos - 2, 2].to_i + pos -= 2 + if pos >= 2 + h[:mon] = s2[pos - 2, 2].to_i + pos -= 2 + h[:year] = (sign.to_s + s2[0, pos]).to_i if pos > 0 + end + end + else + h[:year] = (sign.to_s + s2[0, 4]).to_i + h[:mon] = s2[4, 2].to_i if l2 >= 6 + h[:mday] = s2[6, 2].to_i if l2 >= 8 + h[:hour] = s2[8, 2].to_i if l2 >= 10 + h[:min] = s2[10, 2].to_i if l2 >= 12 + h[:sec] = s2[12, 2].to_i if l2 >= 14 + h[:_comp] = false + end + when 3 + if s3.nil? && s4 + h[:sec] = s2[1, 2].to_i + h[:min] = s2[0, 1].to_i + else + h[:yday] = s2.to_i + end + when 5 + if s3.nil? && s4 + h[:sec] = s2[3, 2].to_i + h[:min] = s2[1, 2].to_i + h[:hour] = s2[0, 1].to_i + else + h[:year] = (sign.to_s + s2[0, 2]).to_i + h[:yday] = s2[2, 3].to_i + end + when 7 + if s3.nil? && s4 + h[:sec] = s2[5, 2].to_i + h[:min] = s2[3, 2].to_i + h[:hour] = s2[1, 2].to_i + h[:mday] = s2[0, 1].to_i + else + h[:year] = (sign.to_s + s2[0, 4]).to_i + h[:yday] = s2[4, 3].to_i + h[:_comp] = false + end + end + + # s3 (time portion from continuous digits after separator) + if s3 && !s3.empty? + l3 = s3.length + if s4 + # read from end + case l3 + when 2 + h[:sec] = s3[0, 2].to_i + when 4 + h[:sec] = s3[2, 2].to_i + h[:min] = s3[0, 2].to_i + when 6 + h[:sec] = s3[4, 2].to_i + h[:min] = s3[2, 2].to_i + h[:hour] = s3[0, 2].to_i + end + else + # read from start + h[:hour] = s3[0, 2].to_i if l3 >= 2 + h[:min] = s3[2, 2].to_i if l3 >= 4 + h[:sec] = s3[4, 2].to_i if l3 >= 6 + end + end + + # s4: sec_fraction + if s4 && !s4.empty? + h[:sec_fraction] = parse_sec_fraction(s4) + end + + # s5: zone + if s5 && !s5.empty? + zone = s5 + if zone.start_with?('[') + zone = zone[1...-1] # strip brackets + # Format: [offset:zonename] or [offset zonename] or [offset] or [zonename] + if (zm = zone.match(/\A([-+]?\d+(?:[,.]\d+)?):(.+)/)) + # +9:JST, -5:EST, +12:XXX YYY ZZZ + h[:zone] = zm[2].strip + off_s = zm[1] + off_s = "+#{off_s}" unless off_s.start_with?('+') || off_s.start_with?('-') + h[:offset] = fast_zone_offset(off_s) + elsif (zm = zone.match(/\A([-+]?\d+(?:[,.]\d+)?)\s+(\S.+)/)) + # Number followed by space and non-empty name + h[:zone] = zm[2] + off_s = zm[1] + off_s = "+#{off_s}" unless off_s.start_with?('+') || off_s.start_with?('-') + h[:offset] = fast_zone_offset(off_s) + else + # Could be just a number with optional trailing space: [9], [-9], [9 ] + h[:zone] = zone + stripped = zone.strip + if stripped =~ /\A([-+]?\d+(?:[,.]\d+)?)\z/ + off_s = $1 + off_s = "+#{off_s}" unless off_s.start_with?('+') || off_s.start_with?('-') + h[:offset] = fast_zone_offset(off_s) + end + end + else + h[:zone] = zone + end + end + + str[m.begin(0)...m.end(0)] = ' ' * (m.end(0) - m.begin(0)) + true + else + false + end + end + + def parse_bc_post(str, h) + if (m = PARSE_BC_RE.match(str)) + h[:_bc] = true + str[m.begin(0)...m.end(0)] = ' ' * (m.end(0) - m.begin(0)) + end + end + + def parse_frag(str, h) + if (m = PARSE_FRAG_RE.match(str)) + v = m[1].to_i + if h.key?(:hour) && !h.key?(:mday) + h[:mday] = v if v >= 1 && v <= 31 + elsif h.key?(:mday) && !h.key?(:hour) + h[:hour] = v if v >= 0 && v <= 24 + end + end + end + + # s3e: 3-element (year, month, day) disambiguation + # Faithfully mirrors the C implementation's s3e() logic. + # Arguments: y, m, d are strings (or Integer for m when month name was parsed) + def s3e(h, y, m, d, bc) + # Fast path: y is unambiguously a year (3+ digits or signed), m and d are simple digits. + # This covers ISO "2001-02-03" but not ambiguous cases like "23/5/1999". + if y && m && d && !bc && y.is_a?(String) && m.is_a?(String) && d.is_a?(String) && + !y.start_with?("'") && !d.start_with?("'") && + y =~ /\A([-+])?\d{3,}\z/ && m =~ /\A\d+\z/ && d =~ /\A\d{1,2}\z/ + h[:year] = y.to_i + h[:_comp] = false + h[:mon] = m.to_i + h[:mday] = d.to_i + return + end + + m = m.to_s if m.is_a?(Integer) + + # Step 1: If y && m are present but d is nil, rotate: d=m, m=y, y=nil + if y && m && d.nil? + d = m + m = y + y = nil + end + + # Step 2: If y is nil but d is present, check if d looks like a year + if y.nil? && d + ds = d.to_s + digits = ds.sub(/\A'?-?/, '').sub(/(?:st|nd|rd|th)\z/i, '') + if digits.length > 2 || ds.start_with?("'") + y = d + d = nil + end + end + + # Step 3: Parse y - extract numeric value and determine _comp flag + year_val = nil + comp_flag = nil + if y + ys_raw = y.to_s + if ys_raw.start_with?("'") + year_val = ys_raw[1..].to_i + comp_flag = true + else + # Match digits (with optional leading sign), check for trailing non-digit + if ys_raw =~ /\A[^-+\d]*([-+]?\d+)/ + num_s = $1 + rest = ys_raw[$~.end(0)..] + if rest && !rest.empty? && rest =~ /[^\d]/ + # trailing non-digit (like "st" in "1st"): this y becomes d, old d becomes y + old_d = d + d = num_s + year_val = nil + comp_flag = nil + if old_d + s3eparse_year(old_d.to_s)&.then do |v, c| + year_val = v + comp_flag = c + end + end + else + year_val = num_s.to_i + if num_s.start_with?('-') || num_s.start_with?('+') || num_s.sub(/\A[-+]/, '').length > 2 + comp_flag = false + end + end + end + end + end + + # Step 4: Check m - if it looks like a year (apostrophe or > 2 digits), swap US→BE + if m.is_a?(String) + ms_digits = m.sub(/\A'?-?/, '').sub(/(?:st|nd|rd|th)\z/i, '') + if m.start_with?("'") || ms_digits.length > 2 + # Rotate: old_y=y, y=m, m=d, d=old_y_string + old_y = y + y = m + m = d + d = old_y + + # Re-parse y + ys = y.to_s.sub(/(?:st|nd|rd|th)\z/i, '') + if ys.start_with?("'") + year_val = ys[1..].to_i + comp_flag = true + elsif ys =~ /([-+]?\d+)/ + num_s = $1 + year_val = num_s.to_i + comp_flag = (num_s.start_with?('-') || num_s.start_with?('+') || num_s.sub(/\A[-+]/, '').length > 2) ? false : nil + end + end + end + + # Step 5: Check d - if it looks like a year, swap with y + if d.is_a?(String) + ds_digits = d.sub(/\A'?-?/, '').sub(/(?:st|nd|rd|th)\z/i, '') + if d.start_with?("'") || ds_digits.length > 2 + old_y = y + # d becomes year + ys = d.sub(/(?:st|nd|rd|th)\z/i, '') + if ys.start_with?("'") + year_val = ys[1..].to_i + comp_flag = true + elsif ys =~ /([-+]?\d+)/ + num_s = $1 + year_val = num_s.to_i + comp_flag = (num_s.start_with?('-') || num_s.start_with?('+') || num_s.sub(/\A[-+]/, '').length > 2) ? false : nil + end + d = old_y + end + end + + # Set year + h[:year] = year_val if year_val + h[:_comp] = comp_flag unless comp_flag.nil? + h[:_bc] = true if bc + + # Set mon + if m + if m.is_a?(String) + ms = m.sub(/(?:st|nd|rd|th)\z/i, '') + h[:mon] = $1.to_i if ms =~ /(\d+)/ + else + h[:mon] = m.to_i + end + end + + # Set mday + if d + if d.is_a?(String) + ds = d.sub(/(?:st|nd|rd|th)\z/i, '') + h[:mday] = $1.to_i if ds =~ /(\d+)/ + else + h[:mday] = d.to_i + end + end + end + + # Helper: parse a string as a year value, returning [year_val, comp_flag] or nil + def s3eparse_year(s) + if s.start_with?("'") + [s[1..].to_i, true] + elsif s =~ /\A[^-+\d]*([-+]?\d+)/ + num_s = $1 + cf = (num_s.start_with?('-') || num_s.start_with?('+') || num_s.sub(/\A[-+]/, '').length > 2) ? false : nil + [num_s.to_i, cf] + end + end + + # ------------------------------------------------------------------ + # Fast Date construction + # ------------------------------------------------------------------ + + # Fast Date construction: when year/mon/mday are all present and no + # complex keys (jd, yday, cwyear, wday, wnum, seconds) exist, skip + # the full _sp_complete_frags pipeline and directly validate civil date. + def fast_new_date(hash, sg) + raise Error, 'invalid date' if hash.nil? || hash.empty? + y = hash[:year] + m = hash[:mon] + d = hash[:mday] + if y && m && d && + !hash.key?(:jd) && !hash.key?(:yday) && !hash.key?(:cwyear) && + !hash.key?(:wnum0) && !hash.key?(:wnum1) && !hash.key?(:seconds) + jd = internal_valid_civil?(y, m, d, sg) + raise Error, 'invalid date' if jd.nil? + new_from_jd(jd, sg) + else + internal_new_by_frags(hash, sg) + end + end + + end +end diff --git a/lib/date/patterns.rb b/lib/date/patterns.rb new file mode 100644 index 00000000..b8437b25 --- /dev/null +++ b/lib/date/patterns.rb @@ -0,0 +1,403 @@ +# frozen_string_literal: true + +class Date + # TIME_PAT + # Regular expression pattern for C's parse_time. + # $1: entire time portion + # $2: time zone portion (optional) + # + # In the zone portion, [A-Za-z] is used for case-sensitive alphabetic characters. + TIME_PAT = / + ( # $1: whole time + \d+\s* # hour (required) + (?: + (?: # Branch A: colon-separated + :\s*\d+ # :min + (?: + \s*:\s*\d+(?:[,.]\d*)? # :sec[.frac] + )? + | # Branch B: h m s separated + h(?:\s*\d+m? + (?:\s*\d+s?)? + )? + ) + (?: # AM PM suffix (optional) + \s*[ap](?:m\b|\.m\.) + )? + | # Branch C: Only AM PM + [ap](?:m\b|\.m\.) + ) + ) + (?: # Time Zone (optional) + \s* + ( # $2: time zone + (?:gmt|utc?)?[-+]\d+ + (?:[,.:]\d+(?::\d+)?)? + | + [[:alpha:].\s]+ + (?:standard|daylight)\stime\b + | + [[:alpha:]]+(?:\sdst)?\b + ) + )? + /xi + private_constant :TIME_PAT + + # TIME_DETAIL_PAT + # Pattern for detailed parsing of time portion + TIME_DETAIL_PAT = / + \A(\d+)\s* # $1 hour + (?: + :\s*(\d+) # $2 min (colon) + (?:\s*:\s*(\d+)([,.]\d*)?)? # $3 sec, $4 frac (colon) + | + h(?:\s*(\d+)m? # $5 min (h) + (?:\s*(\d+)s?)? # $6 sec (h) + )? + )? + (?:\s*([ap])(?:m\b|\.m\.))? # $7 am pm + /xi + private_constant :TIME_DETAIL_PAT + + # PARSE_DAY_PAT + # Non-TIGHT pattern for parse_day. + # Matches abbreviated day name and consumes trailing characters + # (e.g., "urday" in "Saturday") so they get replaced by subx. + PARSE_DAY_PAT = /\b(sun|mon|tue|wed|thu|fri|sat)[^-\/\d\s]*/i + private_constant :PARSE_DAY_PAT + + # ERA1_PAT + # Pattern for AD, A.D. + ERA1_PAT = /\b(a(?:d\b|\.d\.))(?!(? string + # + # Returns a string representation of the date in +self+, + # formatted according the given +format+: + # + # Date.new(2001, 2, 3).strftime # => "2001-02-03" + # + # For other formats, see + # {Formats for Dates and Times}[rdoc-ref:language/strftime_formatting.rdoc]. + def strftime(format = DEFAULT_STRFTIME_FMT) + if format.equal?(DEFAULT_STRFTIME_FMT) + internal_civil unless @year + return fast_ymd.force_encoding(Encoding::US_ASCII) + end + fmt = format.to_str + if fmt == YMD_FMT + internal_civil unless @year + return fast_ymd.force_encoding(Encoding::US_ASCII) + end + internal_strftime(fmt).force_encoding(fmt.encoding) + end + + private + + def internal_strftime(fmt) + # Fast path for common format strings (whole-string match) + case fmt + when '%Y-%m-%d', '%F' + internal_civil unless @year + return fast_ymd + when '%H:%M:%S', '%T', '%X' + return instance_of?(Date) ? +'00:00:00' : "#{PAD2[internal_hour]}:#{PAD2[internal_min]}:#{PAD2[internal_sec]}" + when '%m/%d/%y', '%D', '%x' + internal_civil unless @year + return "#{PAD2[@month]}/#{PAD2[@day]}/#{PAD2[@year.abs % 100]}" + when '%Y-%m-%dT%H:%M:%S%z' + internal_civil unless @year + if instance_of?(Date) + return fast_ymd << 'T00:00:00+0000' + else + of = of_seconds + sign = of < 0 ? '-' : '+' + abs = of.abs + return "#{fast_ymd}T#{PAD2[internal_hour]}:#{PAD2[internal_min]}:#{PAD2[internal_sec]}#{sign}#{PAD2[abs / 3600]}#{PAD2[(abs % 3600) / 60]}" + end + when '%a %b %e %H:%M:%S %Y', '%c' + internal_civil unless @year + return fmt_asctime_str + when '%A, %B %d, %Y' + internal_civil unless @year + w = (@jd + 1) % 7 + y = @year + y_s = y >= 1000 ? y.to_s : (y >= 0 ? format('%04d', y) : format('-%04d', -y)) + return "#{DAY_FULL_COMMA[w]}#{MONTH_FULL_SPACE[@month]}#{PAD2[@day]}, #{y_s}" + end + + result = +''.encode(fmt.encoding) + sc = StringScanner.new(fmt) + + until sc.eos? + # Batch collect literal (non-%) characters + if (lit = sc.scan(/[^%]+/)) + result << lit + next + end + + # Must be '%' or something unexpected + unless sc.skip(/%/) + result << sc.getch + next + end + + if sc.eos? + result << '%' + break + end + + # Quick dispatch: if next char is a simple letter spec (A-Z excluding E/O, + # or a-z), skip flag/width/prec parsing entirely. + if (quick = sc.scan(/[A-DFG-NP-Za-z]/)) + fast = fast_spec(quick.ord) + if fast + result << fast + next + end + sc.unscan + end + + # Parse flags (bitmask) + flags = 0 + colons = 0 + while (f = sc.scan(/[-_0^#]/)) + case f + when '-' then flags |= FL_LEFT + when '_' then flags |= FL_SPACE + when '0' then flags |= FL_ZERO + when '^' then flags |= FL_UPPER + when '#' then flags |= FL_CHCASE + end + end + if (c = sc.scan(/:+/)) + colons = c.length + end + + # Parse width + width = sc.scan(/\d+/)&.to_i + raise Errno::ERANGE, "strftime" if width && width > STRFTIME_MAX_WIDTH + + # Parse precision (after '.') + prec = sc.skip(/\./) ? (sc.scan(/\d+/)&.to_i || 0) : nil + + # Post-width colons (for %8:z, %11::z etc.) + if (c = sc.scan(/:+/)) + colons += c.length + end + + # Locale modifier (%E or %O) + locale_mod = sc.scan(/[EO]/)&.ord + + spec = sc.getch&.ord + + # Inline fast path: no flags, no width, no prec, no locale mod, no colons + if flags == 0 && width.nil? && prec.nil? && locale_mod.nil? && colons == 0 + fast = fast_spec(spec) + if fast + result << fast + next + end + end + + result << format_spec_b(spec, flags, colons, width, prec, locale_mod) + end + + result + end + + # Format "%a %b %e %H:%M:%S %Y" directly (used by %c expansion and asctime) + def fmt_asctime_str + w = (@jd + 1) % 7 + d = @day + d_s = d < 10 ? " #{d}" : d.to_s + y = @year + y_s = y >= 1000 ? y.to_s : (y >= 0 ? format('%04d', y) : format('-%04d', -y)) + if instance_of?(Date) + "#{ASCTIME_PREFIX[w][@month]}#{d_s} 00:00:00 #{y_s}" + else + "#{ASCTIME_PREFIX[w][@month]}#{d_s} #{PAD2[internal_hour]}:#{PAD2[internal_min]}:#{PAD2[internal_sec]} #{y_s}" + end + end + + # Inline fast path for common specs with default formatting (no flags/width/prec) + def fast_spec(spec) + case spec + when 89 # 'Y' + internal_civil unless @year + y = @year + if y >= 1000 + s = y.to_s + raise Errno::ERANGE, "strftime" if s.length >= STRFTIME_MAX_COPY_LEN + s + elsif y >= 0 + format('%04d', y) + else + format('-%04d', -y) + end + when 109 # 'm' + internal_civil unless @year + PAD2[@month] + when 100 # 'd' + internal_civil unless @year + PAD2[@day] + when 101 # 'e' + internal_civil unless @year + d = @day + d < 10 ? " #{d}" : d.to_s + when 72 # 'H' + PAD2[internal_hour] + when 77 # 'M' + PAD2[internal_min] + when 83 # 'S' + PAD2[internal_sec] + when 97 # 'a' + STRFTIME_DAYS_ABBR[(@jd + 1) % 7] + when 65 # 'A' + STRFTIME_DAYS_FULL[(@jd + 1) % 7] + when 98, 104 # 'b', 'h' + internal_civil unless @year + STRFTIME_MONTHS_ABBR[@month] + when 66 # 'B' + internal_civil unless @year + STRFTIME_MONTHS_FULL[@month] + when 112 # 'p' + internal_hour < 12 ? 'AM' : 'PM' + when 80 # 'P' + internal_hour < 12 ? 'am' : 'pm' + when 37 # '%' + '%' + when 110 # 'n' + "\n" + when 116 # 't' + "\t" + when 106 # 'j' + yd = yday + if yd < 10 + "00#{yd}" + elsif yd < 100 + "0#{yd}" + else + yd.to_s + end + when 119 # 'w' + ((@jd + 1) % 7).to_s + when 117 # 'u' + cwday.to_s + when 121 # 'y' + internal_civil unless @year + PAD2[@year.abs % 100] + when 90 # 'Z' + zone_str + when 122 # 'z' + of = of_seconds + sign = of < 0 ? '-' : '+' + abs = of.abs + "#{sign}#{PAD2[abs / 3600]}#{PAD2[(abs % 3600) / 60]}" + when 115 # 's' + ((@jd - 2440588) * 86400 - of_seconds + internal_hour * 3600 + internal_min * 60 + internal_sec).to_s + # Composite specs — inline expansion + when 99 # 'c' + internal_civil unless @year + fmt_asctime_str + when 70 # 'F' + internal_civil unless @year + fast_ymd + when 84, 88 # 'T', 'X' + instance_of?(Date) ? '00:00:00' : "#{PAD2[internal_hour]}:#{PAD2[internal_min]}:#{PAD2[internal_sec]}" + when 68, 120 # 'D', 'x' + internal_civil unless @year + "#{PAD2[@month]}/#{PAD2[@day]}/#{PAD2[@year.abs % 100]}" + when 82 # 'R' + instance_of?(Date) ? '00:00' : "#{PAD2[internal_hour]}:#{PAD2[internal_min]}" + when 114 # 'r' + if instance_of?(Date) + '12:00:00 AM' + else + h = internal_hour % 12 + h = 12 if h == 0 + "#{PAD2[h]}:#{PAD2[internal_min]}:#{PAD2[internal_sec]} #{internal_hour < 12 ? 'AM' : 'PM'}" + end + else + nil # fall through to format_spec_b + end + end + + # Full spec handling with bitmask flags (called when fast_spec returns nil or flags/width/prec present) + def format_spec_b(spec, flags, colons, width, prec, locale_mod) + # Handle %E/%O locale modifiers + if locale_mod + valid = locale_mod == 69 ? STRFTIME_E_VALID_BYTES : STRFTIME_O_VALID_BYTES # 69='E' + unless valid.include?(spec) + mod_chr = locale_mod == 69 ? 'E' : 'O' + return "%#{mod_chr}#{spec&.chr}" + end + end + + case spec + when 89, 71 # 'Y', 'G' + y = spec == 89 ? year : cwyear + fmt_year(y, width, prec, flags) + when 67 # 'C' + cent = year.div(100) + pad_num(cent, width || 2, flags) + when 121 # 'y' + pad_num(year.abs % 100, width || 2, flags, zero: true) + when 103 # 'g' + pad_num(cwyear.abs % 100, width || 2, flags, zero: true) + when 109 # 'm' + pad_num(month, width || 2, flags, zero: true) + when 100 # 'd' + pad_num(day, width || 2, flags, zero: true) + when 101 # 'e' + pad_num(day, width || 2, flags, zero: false) + when 106 # 'j' + pad_num(yday, width || 3, flags, zero: true) + when 72 # 'H' + pad_num(internal_hour, width || 2, flags, zero: true) + when 107 # 'k' + pad_num(internal_hour, width || 2, flags, zero: false) + when 73 # 'I' + h = internal_hour % 12 + h = 12 if h == 0 + pad_num(h, width || 2, flags, zero: true) + when 108 # 'l' + h = internal_hour % 12 + h = 12 if h == 0 + pad_num(h, width || 2, flags, zero: false) + when 77 # 'M' + pad_num(internal_min, width || 2, flags, zero: true) + when 83 # 'S' + pad_num(internal_sec, width || 2, flags, zero: true) + when 76 # 'L' + w = width || 3 + ms = (sec_frac * (10**w)).floor + ms.to_s.rjust(w, '0') + when 78 # 'N' + w = width || prec || 9 + ns = (sec_frac * (10**w)).floor + ns.to_s.rjust(w, '0') + when 115 # 's' + unix = (@jd - 2440588) * 86400 - of_seconds + + internal_hour * 3600 + internal_min * 60 + internal_sec + pad_num(unix, width || 1, flags) + when 81 # 'Q' + ms = ((@jd - 2440588) * 86400 - of_seconds + + internal_hour * 3600 + internal_min * 60 + internal_sec) * 1000 + + (sec_frac * 1000).floor + pad_num(ms, width || 1, flags) + when 65 # 'A' + fmt_str(STRFTIME_DAYS_FULL[wday], width, flags) + when 97 # 'a' + fmt_str(STRFTIME_DAYS_ABBR[wday], width, flags) + when 66 # 'B' + fmt_str(STRFTIME_MONTHS_FULL[month], width, flags) + when 98, 104 # 'b', 'h' + fmt_str(STRFTIME_MONTHS_ABBR[month], width, flags) + when 112 # 'p' + fmt_str(internal_hour < 12 ? 'AM' : 'PM', width, flags) + when 80 # 'P' + fmt_str(internal_hour < 12 ? 'am' : 'pm', width, flags) + when 90 # 'Z' + fmt_str(zone_str, width, flags) + when 122 # 'z' + fmt_z(colons, width, prec, flags) + when 117 # 'u' + pad_num(cwday, width || 1, flags) + when 119 # 'w' + pad_num(wday, width || 1, flags) + when 85 # 'U' + pad_num(week_number(0), width || 2, flags, zero: true) + when 87 # 'W' + pad_num(week_number(1), width || 2, flags, zero: true) + when 86 # 'V' + pad_num(cweek, width || 2, flags, zero: true) + when 37 # '%' + '%' + when 43 # '+' + s = internal_strftime('%a %b %e %H:%M:%S %Z %Y') + fmt_str(s, width, flags) + else + # Try composite + expansion = STRFTIME_COMPOSITE_BYTE[spec] + if expansion + s = internal_strftime(expansion) + fmt_str(s, width, flags) + else + "%#{spec&.chr}" + end + end + end + + # Format year (handles negative years, precision, and all flag variants) + def fmt_year(y, width, prec, flags) + if prec + s = y.abs.to_s.rjust(prec, '0') + s = (y < 0 ? '-' : '') + s + elsif flags & FL_LEFT != 0 + s = (y < 0 ? '-' : '') + y.abs.to_s + else + default_w = y < 0 ? 5 : 4 + w = width || default_w + if flags & FL_SPACE != 0 + raw = (y < 0 ? '-' : '') + y.abs.to_s + s = raw.rjust(w, ' ') + else + s = y.abs.to_s.rjust(w - (y < 0 ? 1 : 0), '0') + s = (y < 0 ? '-' : '') + s + end + end + raise Errno::ERANGE, "strftime" if s.length >= STRFTIME_MAX_COPY_LEN + if flags & (FL_UPPER | FL_CHCASE) != 0 + s.upcase + else + s + end + end + + def pad_num(n, default_w, flags, zero: nil) + sign = n < 0 ? '-' : '' + abs = n.abs.to_s + w = default_w + + if flags & FL_LEFT != 0 + sign + abs + elsif flags & FL_SPACE != 0 || (flags & FL_ZERO == 0 && zero == false) + sign + abs.rjust(w - sign.length, ' ') + else + pad = (flags & FL_ZERO != 0 || zero) ? '0' : ' ' + sign + abs.rjust([w - sign.length, abs.length].max, pad) + end + end + + def fmt_str(s, width, flags) + s = s.dup + if flags & FL_CHCASE != 0 + s = (s == s.upcase) ? s.downcase : s.upcase + elsif flags & FL_UPPER != 0 + s = s.upcase + end + if flags & FL_LEFT != 0 + s + elsif width + pad = flags & FL_ZERO != 0 ? '0' : ' ' + s.rjust(width, pad) + else + s + end + end + + # Week number (0=first partial week) + def week_number(ws) + yd = yday + wd = wday # 0=Sun + if ws == 1 + # Monday-based + wd = wd == 0 ? 6 : wd - 1 + end + (yd - wd + 6).div(7) + end + + # Format %z with colons variant and GNU extension flag support + def fmt_z(colons, width, _prec, flags) + of = of_seconds + sign = of < 0 ? '-' : '+' + abs = of.abs + hh = abs / 3600 + mm = (abs % 3600) / 60 + ss = abs % 60 + + no_lead = flags & (FL_LEFT | FL_SPACE) != 0 + + if no_lead + case colons + when 0 + s = format('%s%d%02d', sign, hh, mm) + when 1 + s = format('%s%d:%02d', sign, hh, mm) + when 2 + s = format('%s%d:%02d:%02d', sign, hh, mm, ss) + when 3 + if ss != 0 + s = format('%s%d:%02d:%02d', sign, hh, mm, ss) + elsif mm != 0 + s = format('%s%d:%02d', sign, hh, mm) + else + s = format('%s%d', sign, hh) + end + else + s = format('%s%d:%02d', sign, hh, mm) + end + else + case colons + when 0 + s = format('%s%02d%02d', sign, hh, mm) + when 1 + s = format('%s%02d:%02d', sign, hh, mm) + when 2 + s = format('%s%02d:%02d:%02d', sign, hh, mm, ss) + when 3 + if ss != 0 + s = format('%s%02d:%02d:%02d', sign, hh, mm, ss) + elsif mm != 0 + s = format('%s%02d:%02d', sign, hh, mm) + else + s = format('%s%02d', sign, hh) + end + else + s = format('%s%02d:%02d', sign, hh, mm) + end + end + + if width + if flags & FL_LEFT != 0 + s + elsif flags & FL_SPACE != 0 + s.rjust(width, ' ') + else + digits = s[1..] + sign + digits.rjust(width - 1, '0') + end + else + s + end + end + + # Fast path helper: format '%Y-%m-%d' without allocation overhead + def fast_ymd + y = @year + suffix = MONTH_DAY_SUFFIX[@month][@day] + if y >= 1000 + y.to_s << suffix + elsif y >= 0 + format('%04d', y) << suffix + else + format('-%04d', -y) << suffix + end + end + + # Helpers for DateTime override + def internal_hour + 0 + end + + def internal_min + 0 + end + + def internal_sec + 0 + end + + def sec_frac + 0r + end + + def of_seconds + 0 + end + + def zone_str + '+00:00' + end + +end diff --git a/lib/date/strptime.rb b/lib/date/strptime.rb new file mode 100644 index 00000000..d905daba --- /dev/null +++ b/lib/date/strptime.rb @@ -0,0 +1,924 @@ +# frozen_string_literal: true + +class Date + class << self + # call-seq: + # Date._strptime(string, format = '%F') -> hash + # + # Returns a hash of values parsed from +string+ + # according to the given +format+: + # + # Date._strptime('2001-02-03', '%Y-%m-%d') # => {:year=>2001, :mon=>2, :mday=>3} + # + # For other formats, see + # {Formats for Dates and Times}[rdoc-ref:language/strftime_formatting.rdoc]. + # (Unlike Date.strftime, does not support flags and width.) + # + # See also {strptime(3)}[https://man7.org/linux/man-pages/man3/strptime.3.html]. + # + # Related: Date.strptime (returns a \Date object). + def _strptime(string, format = '%F') + string = String === string ? string : string.to_str + format = String === format ? format : format.to_str + if format == '%F' || format == '%Y-%m-%d' + return internal_strptime_ymd(string) + end + if format == '%a %b %d %Y' + return internal_strptime_abdy(string) + end + hash = {} + si = catch(:sp_fail) { sp_run(string, 0, format, hash) } + return nil unless si + hash[:leftover] = string[si..] if si < string.length + if (cent = hash.delete(:_cent)) + hash[:year] = hash[:year] + cent * 100 if hash.key?(:year) + hash[:cwyear] = hash[:cwyear] + cent * 100 if hash.key?(:cwyear) + end + if (merid = hash.delete(:_merid)) + hash[:hour] = hash[:hour] % 12 + merid if hash.key?(:hour) + end + hash + end + + # call-seq: + # Date.strptime(string = '-4712-01-01', format = '%F', start = Date::ITALY) -> date + # + # Returns a new \Date object with values parsed from +string+, + # according to the given +format+: + # + # Date.strptime('2001-02-03', '%Y-%m-%d') # => # + # Date.strptime('03-02-2001', '%d-%m-%Y') # => # + # Date.strptime('2001-034', '%Y-%j') # => # + # Date.strptime('2001-W05-6', '%G-W%V-%u') # => # + # Date.strptime('2001 04 6', '%Y %U %w') # => # + # Date.strptime('2001 05 6', '%Y %W %u') # => # + # Date.strptime('sat3feb01', '%a%d%b%y') # => # + # + # For other formats, see + # {Formats for Dates and Times}[rdoc-ref:language/strftime_formatting.rdoc]. + # (Unlike Date.strftime, does not support flags and width.) + # + # See argument {start}[rdoc-ref:language/calendars.rdoc@Argument+start]. + # + # See also {strptime(3)}[https://man7.org/linux/man-pages/man3/strptime.3.html]. + # + # Related: Date._strptime (returns a hash). + def strptime(string = JULIAN_EPOCH_DATE, format = '%F', start = DEFAULT_SG) + str = String === string ? string : string.to_str + if format == '%F' || format == '%Y-%m-%d' + result = internalinternal_strptime_ymd_to_date(str, start) + return result if result + raise Error, 'invalid date' + end + if format == '%a %b %d %Y' + result = internalinternal_strptime_abdy_to_date(str, start) + return result if result + raise Error, 'invalid date' + end + hash = _strptime(string, format) + raise Error, 'invalid date' if hash.nil? + if !hash.key?(:seconds) && !hash.key?(:leftover) && + !hash.key?(:_cent) && !hash.key?(:_merid) && + (year = hash[:year]) && (mon = hash[:mon]) && (mday = hash[:mday]) + jd = internal_valid_civil?(year, mon, mday, start) + raise Error, 'invalid date' if jd.nil? + return new_from_jd(jd, start) + end + internal_new_by_frags(hash, start) + end + + + private + + # Pre-compiled regex constants for sp_run + SP_WHITESPACE = /[ \t\n\r\v\f]+/ + SP_COLONS = /:+/ + SP_EO_CHECK = /[EO]/ + SP_E_COMBO = /E[cCxXyY]/ + SP_O_COMBO = /O[deHImMSuUVwWy]/ + SP_ALPHA3 = /[A-Za-z]{3}/ + SP_SIGN = /[+-]/ + SP_SPACE_OR_DIGIT = / \d|\d{1,2}/ + SP_AMPM_DOT = /[AaPp]\.[Mm]\./ + SP_AMPM = /[AaPp][Mm]/ + SP_NUM_CHECK = /\d|%[EO]?[CDdeFGgHIjkLlMmNQRrSsTUuVvWwXxYy0-9]/ + SP_DIGITS_1 = /\d/ + SP_DIGITS_2 = /\d{1,2}/ + SP_DIGITS_3 = /\d{1,3}/ + SP_DIGITS_4 = /\d{1,4}/ + SP_DIGITS_9 = /\d{1,9}/ + SP_DIGITS_MAX = /\d+/ + private_constant :SP_WHITESPACE, :SP_COLONS, :SP_EO_CHECK, :SP_E_COMBO, + :SP_O_COMBO, :SP_ALPHA3, :SP_SIGN, :SP_SPACE_OR_DIGIT, + :SP_AMPM_DOT, :SP_AMPM, :SP_NUM_CHECK, + :SP_DIGITS_1, :SP_DIGITS_2, :SP_DIGITS_3, :SP_DIGITS_4, + :SP_DIGITS_9, :SP_DIGITS_MAX + + # Core scanner: walks format string and string simultaneously using StringScanner. + # Returns new string position on success; throws :sp_fail on failure. + def sp_run(str, si, fmt, hash) + fmt_sc = StringScanner.new(fmt) + str_sc = StringScanner.new(str) + str_sc.pos = si + + until fmt_sc.eos? + # Whitespace in format: skip any whitespace in both format and string + if fmt_sc.skip(SP_WHITESPACE) + str_sc.skip(SP_WHITESPACE) + next + end + + # Non-% literal: must match exactly + unless fmt_sc.check(/%/) + fc = fmt_sc.getch + throw(:sp_fail) if str_sc.eos? + throw(:sp_fail) if str_sc.getch != fc + next + end + + fmt_sc.skip(/%/) # skip '%' + + # Handle colon modifiers: %:z, %::z, %:::z + if fmt_sc.scan(SP_COLONS) + throw(:sp_fail) unless fmt_sc.skip(/z/) + str_sc.pos = sp_zone(str, str_sc.pos, str.bytesize, hash) + next + end + + # Handle E/O locale modifiers + if fmt_sc.check(SP_EO_CHECK) + if fmt_sc.check(SP_E_COMBO) || fmt_sc.check(SP_O_COMBO) + fmt_sc.skip(SP_EO_CHECK) # skip modifier, fall through to spec + else + # Invalid combo: match '%' literally in string + throw(:sp_fail) if str_sc.eos? || str_sc.peek(1) != '%' + str_sc.skip(/%/) + next + end + end + + spec_ch = fmt_sc.getch + spec = spec_ch&.ord + + case spec + when 65, 97 # 'A', 'a' + s3 = str_sc.scan(SP_ALPHA3) + throw(:sp_fail) unless s3 + key = compute_3key(s3) + entry = ABBR_DAY_3KEY[key] + throw(:sp_fail) unless entry + wday_i = entry[0] + remaining = entry[1] - 3 + if remaining > 0 + tail = str_sc.peek(remaining) + if tail.length == remaining && tail.downcase == DAY_LOWER_STRS[wday_i][3..] + str_sc.pos += remaining + end + end + hash[:wday] = wday_i + + when 66, 98, 104 # 'B', 'b', 'h' + s3 = str_sc.scan(SP_ALPHA3) + throw(:sp_fail) unless s3 + key = compute_3key(s3) + entry = ABBR_MONTH_3KEY[key] + throw(:sp_fail) unless entry + mon_i = entry[0] + remaining = entry[1] - 3 + if remaining > 0 + tail = str_sc.peek(remaining) + if tail.length == remaining && tail.downcase == MONTH_LOWER_STRS[mon_i][3..] + str_sc.pos += remaining + end + end + hash[:mon] = mon_i + + when 67 # 'C' + num_next = !fmt_sc.eos? && fmt_sc.check(SP_NUM_CHECK) + if str_sc.scan(SP_SIGN) + sign = str_sc.matched == '-' ? -1 : 1 + else + sign = 1 + end + s = str_sc.scan(num_next ? SP_DIGITS_2 : SP_DIGITS_MAX) + throw(:sp_fail) unless s + hash[:_cent] = sign * s.to_i + + when 99 # 'c' + str_sc.pos = sp_run(str, str_sc.pos, '%a %b %e %H:%M:%S %Y', hash) + + when 68 # 'D' + str_sc.pos = sp_run(str, str_sc.pos, '%m/%d/%y', hash) + + when 100, 101 # 'd', 'e' + s = str_sc.scan(SP_SPACE_OR_DIGIT) + throw(:sp_fail) unless s + n = s.to_i + throw(:sp_fail) if n < 1 || n > 31 + hash[:mday] = n + + when 70 # 'F' + str_sc.pos = sp_run(str, str_sc.pos, '%Y-%m-%d', hash) + + when 71 # 'G' + if str_sc.scan(SP_SIGN) + sign = str_sc.matched == '-' ? -1 : 1 + else + sign = 1 + end + num_next = !fmt_sc.eos? && fmt_sc.check(SP_NUM_CHECK) + s = str_sc.scan(num_next ? SP_DIGITS_4 : SP_DIGITS_MAX) + throw(:sp_fail) unless s + hash[:cwyear] = sign * s.to_i + + when 103 # 'g' + s = str_sc.scan(SP_DIGITS_2) + throw(:sp_fail) unless s + n = s.to_i + throw(:sp_fail) if n > 99 + hash[:cwyear] = n + hash[:_cent] ||= n >= 69 ? 19 : 20 + + when 72, 107 # 'H', 'k' + s = str_sc.scan(SP_SPACE_OR_DIGIT) + throw(:sp_fail) unless s + n = s.to_i + throw(:sp_fail) if n > 24 + hash[:hour] = n + + when 73, 108 # 'I', 'l' + s = str_sc.scan(SP_SPACE_OR_DIGIT) + throw(:sp_fail) unless s + n = s.to_i + throw(:sp_fail) if n < 1 || n > 12 + hash[:hour] = n + + when 106 # 'j' + s = str_sc.scan(SP_DIGITS_3) + throw(:sp_fail) unless s + n = s.to_i + throw(:sp_fail) if n < 1 || n > 366 + hash[:yday] = n + + when 76 # 'L' + if str_sc.scan(SP_SIGN) + sign = str_sc.matched == '-' ? -1 : 1 + else + sign = 1 + end + osi = str_sc.pos + num_next = !fmt_sc.eos? && fmt_sc.check(SP_NUM_CHECK) + s = str_sc.scan(num_next ? SP_DIGITS_3 : SP_DIGITS_MAX) + throw(:sp_fail) unless s + n = s.to_i + n = -n if sign == -1 + hash[:sec_fraction] = Rational(n, 10**(str_sc.pos - osi)) + + when 77 # 'M' + s = str_sc.scan(SP_DIGITS_2) + throw(:sp_fail) unless s + n = s.to_i + throw(:sp_fail) if n > 59 + hash[:min] = n + + when 109 # 'm' + s = str_sc.scan(SP_DIGITS_2) + throw(:sp_fail) unless s + n = s.to_i + throw(:sp_fail) if n < 1 || n > 12 + hash[:mon] = n + + when 78 # 'N' + if str_sc.scan(SP_SIGN) + sign = str_sc.matched == '-' ? -1 : 1 + else + sign = 1 + end + osi = str_sc.pos + num_next = !fmt_sc.eos? && fmt_sc.check(SP_NUM_CHECK) + s = str_sc.scan(num_next ? SP_DIGITS_9 : SP_DIGITS_MAX) + throw(:sp_fail) unless s + n = s.to_i + n = -n if sign == -1 + hash[:sec_fraction] = Rational(n, 10**(str_sc.pos - osi)) + + when 110, 116 # 'n', 't' + str_sc.pos = sp_run(str, str_sc.pos, ' ', hash) + + when 80, 112 # 'P', 'p' + throw(:sp_fail) if str_sc.eos? + c0 = str_sc.peek(1) + if c0 == 'P' || c0 == 'p' + merid = 12 + elsif c0 == 'A' || c0 == 'a' + merid = 0 + else + throw(:sp_fail) + end + unless str_sc.scan(SP_AMPM_DOT) || str_sc.scan(SP_AMPM) + throw(:sp_fail) + end + hash[:_merid] = merid + + when 81 # 'Q' + sign = 1 + if str_sc.skip(/-/) + sign = -1 + end + s = str_sc.scan(SP_DIGITS_MAX) + throw(:sp_fail) unless s + n = s.to_i + n = -n if sign == -1 + hash[:seconds] = Rational(n, 1000) + + when 82 # 'R' + str_sc.pos = sp_run(str, str_sc.pos, '%H:%M', hash) + + when 114 # 'r' + str_sc.pos = sp_run(str, str_sc.pos, '%I:%M:%S %p', hash) + + when 83 # 'S' + s = str_sc.scan(SP_DIGITS_2) + throw(:sp_fail) unless s + n = s.to_i + throw(:sp_fail) if n > 60 + hash[:sec] = n + + when 115 # 's' + sign = 1 + if str_sc.skip(/-/) + sign = -1 + end + s = str_sc.scan(SP_DIGITS_MAX) + throw(:sp_fail) unless s + n = s.to_i + n = -n if sign == -1 + hash[:seconds] = n + + when 84 # 'T' + str_sc.pos = sp_run(str, str_sc.pos, '%H:%M:%S', hash) + + when 85 # 'U' + s = str_sc.scan(SP_DIGITS_2) + throw(:sp_fail) unless s + n = s.to_i + throw(:sp_fail) if n > 53 + hash[:wnum0] = n + + when 117 # 'u' + s = str_sc.scan(SP_DIGITS_1) + throw(:sp_fail) unless s + n = s.to_i + throw(:sp_fail) if n < 1 || n > 7 + hash[:cwday] = n + + when 86 # 'V' + s = str_sc.scan(SP_DIGITS_2) + throw(:sp_fail) unless s + n = s.to_i + throw(:sp_fail) if n < 1 || n > 53 + hash[:cweek] = n + + when 118 # 'v' + str_sc.pos = sp_run(str, str_sc.pos, '%e-%b-%Y', hash) + + when 87 # 'W' + s = str_sc.scan(SP_DIGITS_2) + throw(:sp_fail) unless s + n = s.to_i + throw(:sp_fail) if n > 53 + hash[:wnum1] = n + + when 119 # 'w' + s = str_sc.scan(SP_DIGITS_1) + throw(:sp_fail) unless s + n = s.to_i + throw(:sp_fail) if n > 6 + hash[:wday] = n + + when 88 # 'X' + str_sc.pos = sp_run(str, str_sc.pos, '%H:%M:%S', hash) + + when 120 # 'x' + str_sc.pos = sp_run(str, str_sc.pos, '%m/%d/%y', hash) + + when 89 # 'Y' + if str_sc.scan(SP_SIGN) + sign = str_sc.matched == '-' ? -1 : 1 + else + sign = 1 + end + num_next = !fmt_sc.eos? && fmt_sc.check(SP_NUM_CHECK) + s = str_sc.scan(num_next ? SP_DIGITS_4 : SP_DIGITS_MAX) + throw(:sp_fail) unless s + hash[:year] = sign * s.to_i + + when 121 # 'y' + s = str_sc.scan(SP_DIGITS_2) + throw(:sp_fail) unless s + n = s.to_i + throw(:sp_fail) if n > 99 + hash[:year] = n + hash[:_cent] ||= n >= 69 ? 19 : 20 + + when 90, 122 # 'Z', 'z' + str_sc.pos = sp_zone(str, str_sc.pos, str.bytesize, hash) + + when 37 # '%' + throw(:sp_fail) if str_sc.eos? || str_sc.peek(1) != '%' + str_sc.skip(/%/) + + when 43 # '+' + str_sc.pos = sp_run(str, str_sc.pos, '%a %b %e %H:%M:%S %Z %Y', hash) + + else + # Unknown spec: match '%' then spec literally + throw(:sp_fail) if str_sc.eos? || str_sc.peek(1) != '%' + str_sc.skip(/%/) + if spec_ch + throw(:sp_fail) if str_sc.eos? || str_sc.peek(1) != spec_ch + str_sc.getch + end + end + end + + str_sc.pos + end + + # Fast path for %Y-%m-%d / %F format. + # Uses match? + byteslice to avoid StringScanner allocation overhead. + STRPTIME_YMD_EXACT = /\A\d{4}-\d{2}-\d{2}\z/ + STRPTIME_YMD_PREFIX = /\A\d{4}-\d{2}-\d{2}/ + STRPTIME_YMD_GENERAL = /\A([+-]?\d+)-(\d{1,2})-(\d{1,2})(.*)\z/m + private_constant :STRPTIME_YMD_EXACT, :STRPTIME_YMD_PREFIX, :STRPTIME_YMD_GENERAL + + def internal_strptime_ymd(str) + slen = str.bytesize + + # Fast path for "YYYY-MM-DD" (exactly 10 chars) + if slen == 10 && STRPTIME_YMD_EXACT.match?(str) + year = str.byteslice(0, 4).to_i + mon = str.byteslice(5, 2).to_i + mday = str.byteslice(8, 2).to_i + return nil if mon < 1 || mon > 12 || mday < 1 || mday > 31 + return { year: year, mon: mon, mday: mday } + end + + # Medium path for "YYYY-MM-DD..." (10+ chars, standard 4-digit year with leftover) + if slen > 10 && STRPTIME_YMD_PREFIX.match?(str) + year = str.byteslice(0, 4).to_i + mon = str.byteslice(5, 2).to_i + mday = str.byteslice(8, 2).to_i + if mon >= 1 && mon <= 12 && mday >= 1 && mday <= 31 + hash = { year: year, mon: mon, mday: mday } + hash[:leftover] = str.byteslice(10..) + return hash + end + end + + # General path for signed years, short years, etc. + m = STRPTIME_YMD_GENERAL.match(str) + return nil unless m + year = m[1].to_i + mon = m[2].to_i + mday = m[3].to_i + return nil if mon < 1 || mon > 12 || mday < 1 || mday > 31 + hash = { year: year, mon: mon, mday: mday } + rest = m[4] + hash[:leftover] = rest unless rest.empty? + hash + end + + # Parse %Y-%m-%d and directly create Date object. + # Returns Date object on success, nil on failure. + # Uses match? + byteslice to avoid StringScanner allocation overhead. + STRPTIME_YMD_GENERAL_EXACT = /\A([+-]?\d+)-(\d{1,2})-(\d{1,2})\z/ + private_constant :STRPTIME_YMD_GENERAL_EXACT + + def internalinternal_strptime_ymd_to_date(str, sg) + slen = str.bytesize + + # Fast path for exactly "YYYY-MM-DD" (10 chars, positive 4-digit year) + if slen == 10 && STRPTIME_YMD_EXACT.match?(str) + year = str.byteslice(0, 4).to_i + mon = str.byteslice(5, 2).to_i + mday = str.byteslice(8, 2).to_i + if mon >= 1 && mon <= 12 && mday >= 1 && mday <= 31 + jd = internal_valid_civil?(year, mon, mday, sg) + return jd ? new_from_jd(jd, sg) : nil + end + return nil + end + + # General path for signed years, non-standard lengths + m = STRPTIME_YMD_GENERAL_EXACT.match(str) + return nil unless m + year = m[1].to_i + mon = m[2].to_i + mday = m[3].to_i + return nil if mon < 1 || mon > 12 || mday < 1 || mday > 31 + + jd = internal_valid_civil?(year, mon, mday, sg) + return nil unless jd + new_from_jd(jd, sg) + end + + # Fast path for "%a %b %d %Y" format. + # Uses single regex match to avoid StringScanner allocation. + STRPTIME_ABDY_PAT = /\A([A-Za-z]{3})([A-Za-z]*) +([A-Za-z]{3})([A-Za-z]*) +(\d{1,2}) +([+-]?\d+)/ + private_constant :STRPTIME_ABDY_PAT + + def internal_strptime_abdy(str) + m = STRPTIME_ABDY_PAT.match(str) + return nil unless m + + # Validate weekday via 3-byte key lookup + key = compute_3key(m[1]) + entry = ABBR_DAY_3KEY[key] + return nil unless entry + wday = entry[0] + day_rest = m[2] + unless day_rest.empty? + return nil unless day_rest.length == entry[1] - 3 && day_rest.downcase == DAY_LOWER_STRS[wday][3..] + end + + # Validate month via 3-byte key lookup + key = compute_3key(m[3]) + entry = ABBR_MONTH_3KEY[key] + return nil unless entry + mon = entry[0] + mon_rest = m[4] + unless mon_rest.empty? + return nil unless mon_rest.length == entry[1] - 3 && mon_rest.downcase == MONTH_LOWER_STRS[mon][3..] + end + + mday = m[5].to_i + return nil if mday < 1 || mday > 31 + year = m[6].to_i + + hash = { year: year, mon: mon, mday: mday, wday: wday } + post = m.post_match + hash[:leftover] = post unless post.empty? + hash + end + + # Parse "%a %b %d %Y" and directly create Date object. + # Uses single regex match to avoid StringScanner allocation. + def internalinternal_strptime_abdy_to_date(str, sg) + m = STRPTIME_ABDY_PAT.match(str) + return nil unless m + return nil unless m.post_match.empty? + + # Validate weekday via 3-byte key lookup + key = compute_3key(m[1]) + entry = ABBR_DAY_3KEY[key] + return nil unless entry + day_rest = m[2] + unless day_rest.empty? + return nil unless day_rest.length == entry[1] - 3 && day_rest.downcase == DAY_LOWER_STRS[entry[0]][3..] + end + + # Validate month via 3-byte key lookup + key = compute_3key(m[3]) + entry = ABBR_MONTH_3KEY[key] + return nil unless entry + mon = entry[0] + mon_rest = m[4] + unless mon_rest.empty? + return nil unless mon_rest.length == entry[1] - 3 && mon_rest.downcase == MONTH_LOWER_STRS[mon][3..] + end + + mday = m[5].to_i + return nil if mday < 1 || mday > 31 + year = m[6].to_i + + jd = internal_valid_civil?(year, mon, mday, sg) + return nil unless jd + new_from_jd(jd, sg) + end + + # Parse zone from string at position si; update hash[:zone] and hash[:offset]. + # Returns new si on success; throws :sp_fail on failure. + def sp_zone(str, si, slen, hash) + m = STRPTIME_ZONE_PAT.match(str[si..]) + throw(:sp_fail) unless m + zone_str = m[1] + hash[:zone] = zone_str + hash[:offset] = sp_zone_to_diff(zone_str) + si + m[0].length + end + + # Convert a zone string to seconds offset from UTC. + # Returns Integer (seconds) or Rational, or nil if unparseable. + # Mirrors date_zone_to_diff() in ext/date/date_parse.c. + def sp_zone_to_diff(zone_str) + # Fast path for common numeric zones + len = zone_str.length + c0 = zone_str[0] + if c0 == '+' || c0 == '-' + sc = StringScanner.new(zone_str) + if sc.scan(/([+-])(\d{2}):?(\d{2})\z/) + sign = sc[1] == '-' ? -1 : 1 + h = sc[2].to_i + m = sc[3].to_i + return nil if h > 23 || m > 59 + return sign * (h * 3600 + m * 60) + end + elsif len == 1 && (c0 == 'Z' || c0 == 'z') + return 0 + elsif len <= 3 + off = ZONE_TABLE[zone_str.downcase] + return off if off + end + + s = zone_str.dup + dst = false + + # Strip trailing " time" (optionally preceded by "standard" or "daylight") + strip_word = lambda do |str, len, word| + n = word.length + return nil unless len > n + return nil unless str[len - n - 1] =~ /[[:space:]]/ + return nil unless str[len - n, n].casecmp(word) == 0 + n += 1 + n += 1 while len > n && str[len - n - 1] =~ /[[:space:]]/ + n + end + + l = s.length + if (w = strip_word.call(s, l, 'time')) + l -= w + if (w2 = strip_word.call(s, l, 'standard')) + l -= w2 + elsif (w2 = strip_word.call(s, l, 'daylight')) + l -= w2 + dst = true + else + l += w # revert + end + elsif (w = strip_word.call(s, l, 'dst')) + l -= w + dst = true + end + + shrunk = s[0, l].gsub(/[[:space:]]+/, ' ').strip + if (offset = ZONE_TABLE[shrunk.downcase]) + return dst ? offset + 3600 : offset + end + + # Numeric parsing + t = s[0, l].strip + t = t[3..] if t =~ /\Agmt/i + t = t[$&.length..] if t =~ /\Autc?/i + return nil unless t && t.length > 0 && (t[0] == '+' || t[0] == '-') + + sign = t[0] == '-' ? -1 : 1 + t = t[1..] + + if (m = t.match(/\A(\d+):(\d+)(?::(\d+))?\z/)) + h = m[1].to_i + mn = m[2].to_i + sc = m[3] ? m[3].to_i : 0 + return nil if h > 23 || mn > 59 || sc > 59 + sign * (h * 3600 + mn * 60 + sc) + elsif (m = t.match(/\A(\d+)[,.](\d*)/)) + h = m[1].to_i + return nil if h > 23 + frac_s = m[2] + n = [frac_s.length, 7].min + digits = frac_s[0, n].to_i + digits += 1 if frac_s.length > n && frac_s[n].to_i >= 5 + sec = digits * 36 + os = if n == 0 + h * 3600 + elsif n == 1 + sec * 10 + h * 3600 + elsif n == 2 + sec + h * 3600 + else + denom = 10**(n - 2) + r = Rational(sec, denom) + h * 3600 + r.denominator == 1 ? r.numerator : r + end + sign == -1 ? -os : os + elsif t =~ /\A(\d+)\z/ + digits = $1 + dlen = digits.length + h = digits[0, 2 - dlen % 2].to_i + mn = dlen >= 3 ? digits[2 - dlen % 2, 2].to_i : 0 + sc = dlen >= 5 ? digits[4 - dlen % 2, 2].to_i : 0 + sign * (h * 3600 + mn * 60 + sc) + else + nil + end + end + + # Rewrite :seconds (from %s/%Q) into jd + time components. + # Offset is applied first (converts UTC epoch to local time). + def sp_rewrite_frags(hash) + seconds = hash.delete(:seconds) + return hash unless seconds + + offset = hash[:offset] || 0 + seconds = seconds + offset if offset != 0 + + d, fr = seconds.divmod(86400) + h, fr = fr.divmod(3600) + m, fr = fr.divmod(60) + s, fr = fr.divmod(1) + + hash[:jd] = 2440588 + d + hash[:hour] = h + hash[:min] = m + hash[:sec] = s + hash[:sec_fraction] = fr + hash + end + + # Complete partial date fragments by filling defaults from today's date. + # Mirrors rt_complete_frags() in C. + def sp_complete_frags(klass, hash) + # Fast path: detect :civil case (most common) without iterating all entries + k = nil + a = nil + if hash.key?(:year) || hash.key?(:mon) || hash.key?(:mday) + civil_n = 0 + civil_n += 1 if hash.key?(:year) + civil_n += 1 if hash.key?(:mon) + civil_n += 1 if hash.key?(:mday) + civil_n += 1 if hash.key?(:hour) + civil_n += 1 if hash.key?(:min) + civil_n += 1 if hash.key?(:sec) + # Check if any other pattern matches better + best_n = civil_n + skip_civil = false + if hash.key?(:jd) # jd entry has 1 element + skip_civil = true if 1 > best_n + end + if hash.key?(:yday) # ordinal [:year, :yday, :hour, :min, :sec] + ord_n = (hash.key?(:year) ? 1 : 0) + 1 + (hash.key?(:hour) ? 1 : 0) + (hash.key?(:min) ? 1 : 0) + (hash.key?(:sec) ? 1 : 0) + skip_civil = true if ord_n > best_n + end + if hash.key?(:cwyear) || hash.key?(:cweek) || hash.key?(:cwday) + com_n = (hash.key?(:cwyear) ? 1 : 0) + (hash.key?(:cweek) ? 1 : 0) + (hash.key?(:cwday) ? 1 : 0) + (hash.key?(:hour) ? 1 : 0) + (hash.key?(:min) ? 1 : 0) + (hash.key?(:sec) ? 1 : 0) + skip_civil = true if com_n > best_n + end + if hash.key?(:wnum0) + wn0_n = (hash.key?(:year) ? 1 : 0) + 1 + (hash.key?(:wday) ? 1 : 0) + (hash.key?(:hour) ? 1 : 0) + (hash.key?(:min) ? 1 : 0) + (hash.key?(:sec) ? 1 : 0) + skip_civil = true if wn0_n > best_n + end + if hash.key?(:wnum1) + wn1_n = (hash.key?(:year) ? 1 : 0) + 1 + (hash.key?(:wday) ? 1 : 0) + (hash.key?(:hour) ? 1 : 0) + (hash.key?(:min) ? 1 : 0) + (hash.key?(:sec) ? 1 : 0) + skip_civil = true if wn1_n > best_n + end + unless skip_civil + k = :civil + a = [:year, :mon, :mday, :hour, :min, :sec] + end + end + + unless k + best_k = nil + best_a = nil + best_n = 0 + COMPLETE_FRAGS_TAB.each do |ek, ea| + n = ea.count { |sym| hash.key?(sym) } + if n > best_n + best_k = ek + best_a = ea + best_n = n + end + end + k = best_k + a = best_a + end + + if k && best_n < a.length + today = nil + case k + when :ordinal + hash[:year] ||= (today ||= Date.today).year + hash[:yday] ||= 1 + when :civil + a.each do |sym| + break unless hash[sym].nil? + hash[sym] = (today ||= Date.today).__send__(sym) + end + hash[:mon] ||= 1 + hash[:mday] ||= 1 + when :commercial + a.each do |sym| + break unless hash[sym].nil? + hash[sym] = (today ||= Date.today).__send__(sym) + end + hash[:cweek] ||= 1 + hash[:cwday] ||= 1 + when :wday + today ||= Date.today + hash[:jd] = (today - today.wday + hash[:wday]).jd + when :wnum0 + a.each do |sym| + break unless hash[sym].nil? + hash[sym] = (today ||= Date.today).year + end + hash[:wnum0] ||= 0 + hash[:wday] ||= 0 + when :wnum1 + a.each do |sym| + break unless hash[sym].nil? + hash[sym] = (today ||= Date.today).year + end + hash[:wnum1] ||= 0 + hash[:wday] ||= 1 + end + end + + if k == :time && klass <= DateTime + hash[:jd] ||= Date.today.jd + end + + hash[:hour] ||= 0 + hash[:min] ||= 0 + hash[:sec] = if hash[:sec].nil? + 0 + elsif hash[:sec] > 59 + 59 + else + hash[:sec] + end + hash + end + + # Convert year/week/wday to Julian Day number. + # f=0: Sunday-based (%U), d=0=Sun..6=Sat + # f=1: Monday-based (%W), d=0=Mon..6=Sun (Mon-based) + # Mirrors c_weeknum_to_jd() in ext/date/date_core.c: + # rjd2 = JD(Jan 1) + 6 (= JD of Jan 7) + # return (rjd2 - MOD((rjd2 - f + 1), 7) - 7) + 7*w + d + def sp_weeknum_to_jd(y, w, d, f, sg) + jd_jan7 = civil_to_jd(y, 1, 1, sg) + 6 + (jd_jan7 - (jd_jan7 - f + 1) % 7 - 7) + 7 * w + d + end + + # Find the Julian Day number from the fragment hash. + # Tries jd, ordinal, civil, commercial, wnum0, wnum1 in order. + # Returns jd integer or nil. + def sp_valid_date_frags_p(hash, sg) + return hash[:jd] if hash[:jd] + + if (yday = hash[:yday]) && (year = hash[:year]) + jd = internal_valid_ordinal?(year, yday, sg) + return jd if jd + end + + if (mday = hash[:mday]) && (mon = hash[:mon]) && (year = hash[:year]) + jd = internal_valid_civil?(year, mon, mday, sg) + return jd if jd + end + + # Commercial (ISO week): prefer cwday, else wday (treating 0 as 7) + wday = hash[:cwday] + if wday.nil? + wday = hash[:wday] + wday = 7 if !wday.nil? && wday == 0 + end + if wday && (week = hash[:cweek]) && (year = hash[:cwyear]) + jd = internal_valid_commercial?(year, week, wday, sg) + return jd if jd + end + + # wnum0 (Sunday-start): prefer wday (0=Sun), else cwday (converting 7→0) + wday = hash[:wday] + if wday.nil? + wday = hash[:cwday] + wday = 0 if !wday.nil? && wday == 7 + end + if wday && (week = hash[:wnum0]) && (year = hash[:year]) + jd = sp_weeknum_to_jd(year, week, wday, 0, sg) + return jd if jd + end + + # wnum1 (Monday-start): convert to Mon-based 0=Mon using (wday-1)%7 + # Uses original wday (0=Sun) or cwday (1=Mon..7=Sun) — no 7→0 conversion here + wday = hash[:wday] + wday = hash[:cwday] if wday.nil? + wday = (wday - 1) % 7 if wday + if wday && (week = hash[:wnum1]) && (year = hash[:year]) + jd = sp_weeknum_to_jd(year, week, wday, 1, sg) + return jd if jd + end + + nil + end + + # Create a Date object from parsed fragment hash. + def internal_new_by_frags(hash, sg) + raise Error, 'invalid date' if hash.nil? + hash = sp_rewrite_frags(hash) + hash = sp_complete_frags(Date, hash) + jd = sp_valid_date_frags_p(hash, sg) + raise Error, 'invalid date' if jd.nil? + new_from_jd(jd, sg) + end + end +end diff --git a/lib/date/time.rb b/lib/date/time.rb new file mode 100644 index 00000000..cc463b5c --- /dev/null +++ b/lib/date/time.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class Time + # call-seq: + # t.to_time -> time + # + # Returns self. + def to_time + self + end unless method_defined?(:to_time) + + # call-seq: + # t.to_date -> date + # + # Returns a Date object which denotes self. + def to_date + jd = Date.__send__(:gregorian_to_jd, year, mon, mday) + Date.__send__(:new_from_jd, jd, Date::ITALY) + end unless method_defined?(:to_date) + + # call-seq: + # t.to_datetime -> datetime + # + # Returns a DateTime object which denotes self. + def to_datetime + jd = Date.__send__(:gregorian_to_jd, year, mon, mday) + dt = DateTime.allocate + dt.__send__(:_init_datetime, jd, hour, min, sec, subsec, utc_offset, Date::ITALY) + dt + end unless method_defined?(:to_datetime) +end diff --git a/lib/date/version.rb b/lib/date/version.rb new file mode 100644 index 00000000..063fe245 --- /dev/null +++ b/lib/date/version.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class Date + VERSION = "3.5.1" # :nodoc: +end diff --git a/lib/date/zonetab.rb b/lib/date/zonetab.rb new file mode 100644 index 00000000..b7e22c37 --- /dev/null +++ b/lib/date/zonetab.rb @@ -0,0 +1,325 @@ +# frozen_string_literal: true + +# Timezone name => UTC offset (seconds) mapping table. +# Auto-generated from ext/date/zonetab.list by ext/date/generate-zonetab-rb. +# Do not edit manually. +class Date + ZONE_TABLE = { + "a" => 3600, + "acdt" => 37800, + "acst" => 34200, + "act" => -18000, + "acwst" => 31500, + "adt" => -10800, + "aedt" => 39600, + "aest" => 36000, + "afghanistan" => 16200, + "aft" => 16200, + "ahst" => -36000, + "akdt" => -28800, + "akst" => -32400, + "alaskan" => -32400, + "almt" => 21600, + "anast" => 43200, + "anat" => 43200, + "aoe" => -43200, + "aqtt" => 18000, + "arab" => 10800, + "arabian" => 14400, + "arabic" => 10800, + "art" => -10800, + "ast" => -14400, + "at" => -7200, + "atlantic" => -14400, + "aus central" => 34200, + "aus eastern" => 36000, + "awdt" => 32400, + "awst" => 28800, + "azores" => -3600, + "azost" => 0, + "azot" => -3600, + "azst" => 18000, + "azt" => 14400, + "b" => 7200, + "bnt" => 28800, + "bot" => -14400, + "brst" => -7200, + "brt" => -10800, + "bst" => 3600, + "bt" => 10800, + "btt" => 21600, + "c" => 10800, + "canada central" => -21600, + "cape verde" => -3600, + "cast" => 28800, + "cat" => 7200, + "caucasus" => 14400, + "cct" => 23400, + "cdt" => -18000, + "cen. australia" => 34200, + "central" => -21600, + "central america" => -21600, + "central asia" => 21600, + "central europe" => 3600, + "central european" => 3600, + "central pacific" => 39600, + "cest" => 7200, + "cet" => 3600, + "chadt" => 49500, + "chast" => 45900, + "china" => 28800, + "chost" => 32400, + "chot" => 28800, + "chst" => 36000, + "chut" => 36000, + "cidst" => -14400, + "cist" => -18000, + "ckt" => -36000, + "clst" => -10800, + "clt" => -14400, + "cot" => -18000, + "cst" => -21600, + "cvt" => -3600, + "cxt" => 25200, + "d" => 14400, + "dateline" => -43200, + "davt" => 25200, + "ddut" => 36000, + "e" => 18000, + "e. africa" => 10800, + "e. australia" => 36000, + "e. europe" => 7200, + "e. south america" => -10800, + "eadt" => 39600, + "easst" => -18000, + "east" => -21600, + "eastern" => -18000, + "eat" => 10800, + "ect" => -18000, + "edt" => -14400, + "eest" => 10800, + "eet" => 7200, + "egst" => 0, + "egt" => -3600, + "egypt" => 7200, + "ekaterinburg" => 18000, + "est" => -18000, + "f" => 21600, + "fet" => 10800, + "fiji" => 43200, + "fjst" => 46800, + "fjt" => 43200, + "fkst" => -10800, + "fkt" => -14400, + "fle" => 7200, + "fnt" => -7200, + "fst" => 7200, + "fwt" => 3600, + "g" => 25200, + "galt" => -21600, + "gamt" => -32400, + "get" => 14400, + "gft" => -10800, + "gilt" => 43200, + "gmt" => 0, + "greenland" => -10800, + "greenwich" => 0, + "gst" => 36000, + "gtb" => 7200, + "gyt" => -14400, + "h" => 28800, + "hadt" => -32400, + "hast" => -36000, + "hawaiian" => -36000, + "hdt" => -32400, + "hkt" => 28800, + "hovst" => 28800, + "hovt" => 25200, + "hst" => -36000, + "i" => 32400, + "ict" => 25200, + "idle" => 43200, + "idlw" => -43200, + "idt" => 10800, + "india" => 19800, + "iot" => 21600, + "iran" => 12600, + "irdt" => 16200, + "irkst" => 32400, + "irkt" => 28800, + "irst" => 12600, + "ist" => 19800, + "jerusalem" => 7200, + "jst" => 32400, + "k" => 36000, + "kgt" => 21600, + "korea" => 32400, + "kost" => 39600, + "krast" => 28800, + "krat" => 25200, + "kst" => 32400, + "kuyt" => 14400, + "l" => 39600, + "lhdt" => 39600, + "lhst" => 37800, + "lint" => 50400, + "m" => 43200, + "magst" => 43200, + "magt" => 39600, + "malay peninsula" => 28800, + "mart" => -30600, + "mawt" => 18000, + "mdt" => -21600, + "mest" => 7200, + "mesz" => 7200, + "met" => 3600, + "mewt" => 3600, + "mexico" => -21600, + "mez" => 3600, + "mht" => 43200, + "mid-atlantic" => -7200, + "mmt" => 23400, + "mountain" => -25200, + "msd" => 14400, + "msk" => 10800, + "mst" => -25200, + "mut" => 14400, + "mvt" => 18000, + "myanmar" => 23400, + "myt" => 28800, + "n" => -3600, + "n. central asia" => 21600, + "nct" => 39600, + "ndt" => -5400, + "nepal" => 20700, + "new zealand" => 43200, + "newfoundland" => -12600, + "nfdt" => 43200, + "nft" => 39600, + "north asia" => 25200, + "north asia east" => 28800, + "novst" => 25200, + "novt" => 25200, + "npt" => 20700, + "nrt" => 43200, + "nst" => -9000, + "nt" => -39600, + "nut" => -39600, + "nzdt" => 46800, + "nzst" => 43200, + "nzt" => 43200, + "o" => -7200, + "omsst" => 25200, + "omst" => 21600, + "orat" => 18000, + "p" => -10800, + "pacific" => -28800, + "pacific sa" => -14400, + "pdt" => -25200, + "pet" => -18000, + "petst" => 43200, + "pett" => 43200, + "pgt" => 36000, + "phot" => 46800, + "pht" => 28800, + "pkt" => 18000, + "pmdt" => -7200, + "pmst" => -10800, + "pont" => 39600, + "pst" => -28800, + "pwt" => 32400, + "pyst" => -10800, + "q" => -14400, + "qyzt" => 21600, + "r" => -18000, + "ret" => 14400, + "romance" => 3600, + "rott" => -10800, + "russian" => 10800, + "s" => -21600, + "sa eastern" => -10800, + "sa pacific" => -18000, + "sa western" => -14400, + "sakt" => 39600, + "samoa" => -39600, + "samt" => 14400, + "sast" => 7200, + "sbt" => 39600, + "sct" => 14400, + "se asia" => 25200, + "sgt" => 28800, + "south africa" => 7200, + "sret" => 39600, + "sri lanka" => 21600, + "srt" => -10800, + "sst" => -39600, + "swt" => 3600, + "syot" => 10800, + "t" => -25200, + "taht" => -36000, + "taipei" => 28800, + "tasmania" => 36000, + "tft" => 18000, + "tjt" => 18000, + "tkt" => 46800, + "tlt" => 32400, + "tmt" => 18000, + "tokyo" => 32400, + "tonga" => 46800, + "tost" => 50400, + "tot" => 46800, + "trt" => 10800, + "tvt" => 43200, + "u" => -28800, + "ulast" => 32400, + "ulat" => 28800, + "us eastern" => -18000, + "us mountain" => -25200, + "ut" => 0, + "utc" => 0, + "uyst" => -7200, + "uyt" => -10800, + "uzt" => 18000, + "v" => -32400, + "vet" => -14400, + "vladivostok" => 36000, + "vlast" => 39600, + "vlat" => 36000, + "vost" => 21600, + "vut" => 39600, + "w" => -36000, + "w. australia" => 28800, + "w. central africa" => 3600, + "w. europe" => 3600, + "wadt" => 28800, + "wakt" => 43200, + "warst" => -10800, + "wast" => 7200, + "wat" => 3600, + "west" => 3600, + "west asia" => 18000, + "west pacific" => 36000, + "wet" => 0, + "wft" => 43200, + "wgst" => -3600, + "wgt" => -7200, + "wib" => 25200, + "wit" => 32400, + "wita" => 28800, + "wt" => 0, + "x" => -39600, + "y" => -43200, + "yakst" => 36000, + "yakt" => 32400, + "yakutsk" => 32400, + "yapt" => 36000, + "ydt" => -28800, + "yekst" => 21600, + "yekt" => 18000, + "yst" => -32400, + "z" => 0, + "zp4" => 14400, + "zp5" => 18000, + "zp6" => 21600, + }.freeze +end