From bae5812eab6c5e11cc11ff259a7f6f28bd95b976 Mon Sep 17 00:00:00 2001 From: jinroq Date: Wed, 11 Feb 2026 21:55:57 +0900 Subject: [PATCH 1/3] Replace Date and DateTime classes from C to Ruby. C implementation has been rewritten as faithfully as possible in pure Ruby. [Feature #21264] https://bugs.ruby-lang.org/issues/21264 --- Rakefile | 61 +- date.gemspec | 24 +- ext/date/extconf.rb | 21 +- lib/date.rb | 17 +- lib/date/constants.rb | 182 ++ lib/date/core.rb | 3693 +++++++++++++++++++++++++++++++++++++++++ lib/date/datetime.rb | 826 +++++++++ lib/date/parse.rb | 2607 +++++++++++++++++++++++++++++ lib/date/patterns.rb | 403 +++++ lib/date/strftime.rb | 600 +++++++ lib/date/strptime.rb | 769 +++++++++ lib/date/time.rb | 59 + lib/date/version.rb | 5 + lib/date/zonetab.rb | 405 +++++ 14 files changed, 9619 insertions(+), 53 deletions(-) create mode 100644 lib/date/constants.rb create mode 100644 lib/date/core.rb create mode 100644 lib/date/datetime.rb create mode 100644 lib/date/parse.rb create mode 100644 lib/date/patterns.rb create mode 100644 lib/date/strftime.rb create mode 100644 lib/date/strptime.rb create mode 100644 lib/date/time.rb create mode 100644 lib/date/version.rb create mode 100644 lib/date/zonetab.rb diff --git a/Rakefile b/Rakefile index 933384c..06175d0 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 cb439bd..d8da6dd 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 8a1467d..f00d877 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/lib/date.rb b/lib/date.rb index 0cb7630..3ad70c9 100644 --- a/lib/date.rb +++ b/lib/date.rb @@ -1,11 +1,20 @@ # frozen_string_literal: true # date.rb: Written by Tadayoshi Funaba 1998-2011 -require 'date_core' +if RUBY_VERSION >= "3.3" + require_relative "date/version" + require_relative "date/constants" + 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 - VERSION = "3.5.1" # :nodoc: - # call-seq: # infinite? -> false # @@ -64,7 +73,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 0000000..d9db674 --- /dev/null +++ b/lib/date/constants.rb @@ -0,0 +1,182 @@ +# frozen_string_literal: true + +# Constants +class Date + HAVE_JD = 0b00000001 # 1 + HAVE_DF = 0b00000010 # 2 + HAVE_CIVIL = 0b00000100 # 4 + HAVE_TIME = 0b00001000 # 8 + COMPLEX_DAT = 0b10000000 # 128 + private_constant :HAVE_JD, :HAVE_DF, :HAVE_CIVIL, :HAVE_TIME, :COMPLEX_DAT + + MONTHNAMES = [nil, "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December"] + .map { |s| s&.encode(Encoding::US_ASCII)&.freeze }.freeze + ABBR_MONTHNAMES = [nil, "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] + .map { |s| s&.encode(Encoding::US_ASCII)&.freeze }.freeze + DAYNAMES = %w[Sunday Monday Tuesday Wednesday Thursday Friday Saturday] + .map { |s| s.encode(Encoding::US_ASCII).freeze }.freeze + ABBR_DAYNAMES = %w[Sun Mon Tue Wed Thu Fri Sat] + .map { |s| s.encode(Encoding::US_ASCII).freeze }.freeze + + # Pattern constants for regex + ABBR_DAYS_PATTERN = 'sun|mon|tue|wed|thu|fri|sat' + DAYS_PATTERN = 'sunday|monday|tuesday|wednesday|thursday|friday|saturday' + ABBR_MONTHS_PATTERN = 'jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec' + private_constant :ABBR_DAYS_PATTERN, :DAYS_PATTERN, :ABBR_MONTHS_PATTERN + + ITALY = 2299161 # 1582-10-15 + ENGLAND = 2361222 # 1752-09-14 + JULIAN = Float::INFINITY + GREGORIAN = -Float::INFINITY + + DEFAULT_SG = ITALY + private_constant :DEFAULT_SG + + MINUTE_IN_SECONDS = 60 + HOUR_IN_SECONDS = 3600 + DAY_IN_SECONDS = 86400 + HALF_DAYS_IN_SECONDS = DAY_IN_SECONDS / 2 + SECOND_IN_MILLISECONDS = 1000 + SECOND_IN_NANOSECONDS = 1_000_000_000 + private_constant :MINUTE_IN_SECONDS, :HOUR_IN_SECONDS, :DAY_IN_SECONDS, :SECOND_IN_MILLISECONDS, :SECOND_IN_NANOSECONDS, :HALF_DAYS_IN_SECONDS + + JC_PERIOD0 = 1461 # 365.25 * 4 + GC_PERIOD0 = 146097 # 365.2425 * 400 + CM_PERIOD0 = 71149239 # (lcm 7 1461 146097) + CM_PERIOD = (0xfffffff / CM_PERIOD0) * CM_PERIOD0 + CM_PERIOD_JCY = (CM_PERIOD / JC_PERIOD0) * 4 + CM_PERIOD_GCY = (CM_PERIOD / GC_PERIOD0) * 400 + private_constant :JC_PERIOD0, :GC_PERIOD0, :CM_PERIOD0, :CM_PERIOD, :CM_PERIOD_JCY, :CM_PERIOD_GCY + + REFORM_BEGIN_YEAR = 1582 + REFORM_END_YEAR = 1930 + REFORM_BEGIN_JD = 2298874 # ns 1582-01-01 + REFORM_END_JD = 2426355 # os 1930-12-31 + private_constant :REFORM_BEGIN_YEAR, :REFORM_END_YEAR, :REFORM_BEGIN_JD, :REFORM_END_JD + + SEC_WIDTH = 6 + MIN_WIDTH = 6 + HOUR_WIDTH = 5 + MDAY_WIDTH = 5 + MON_WIDTH = 4 + private_constant :SEC_WIDTH, :MIN_WIDTH, :HOUR_WIDTH, :MDAY_WIDTH, :MON_WIDTH + + SEC_SHIFT = 0 + MIN_SHIFT = SEC_WIDTH + HOUR_SHIFT = MIN_WIDTH + SEC_WIDTH + MDAY_SHIFT = HOUR_WIDTH + MIN_WIDTH + SEC_WIDTH + MON_SHIFT = MDAY_WIDTH + HOUR_WIDTH + MIN_WIDTH + SEC_WIDTH + private_constant :SEC_SHIFT, :MIN_SHIFT, :HOUR_SHIFT, :MDAY_SHIFT, :MON_SHIFT + + PK_MASK = ->(x) { (1 << x) - 1 } + private_constant :PK_MASK + + # Days in each month (non-leap and leap year) + MONTH_DAYS = [ + [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31], # non-leap + [0, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] # leap + ].freeze + private_constant :MONTH_DAYS + + YEARTAB = [ + [0, 0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334], # non-leap + [0, 0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335] # leap + ].freeze + private_constant :YEARTAB + + # Neri-Schneider algorithm constants + # JDN of March 1, Year 0 in proleptic Gregorian calendar + NS_EPOCH = 1721120 + private_constant :NS_EPOCH + + # Days in a 4-year cycle (3 normal years + 1 leap year) + NS_DAYS_IN_4_YEARS = 1461 + private_constant :NS_DAYS_IN_4_YEARS + + # Days in a 400-year Gregorian cycle (97 leap years in 400 years) + NS_DAYS_IN_400_YEARS = 146097 + private_constant :NS_DAYS_IN_400_YEARS + + # Years per century + NS_YEARS_PER_CENTURY = 100 + private_constant :NS_YEARS_PER_CENTURY + + # Multiplier for extracting year within century using fixed-point arithmetic. + # This is ceil(2^32 / NS_DAYS_IN_4_YEARS) for the Euclidean affine function. + NS_YEAR_MULTIPLIER = 2939745 + private_constant :NS_YEAR_MULTIPLIER + + # Coefficients for month calculation from day-of-year. + # Maps day-of-year to month using: month = (NS_MONTH_COEFF * doy + NS_MONTH_OFFSET) >> 16 + NS_MONTH_COEFF = 2141 + NS_MONTH_OFFSET = 197913 + private_constant :NS_MONTH_COEFF, :NS_MONTH_OFFSET + + # Coefficients for civil date to JDN month contribution. + # Maps month to accumulated days: days = (NS_CIVIL_MONTH_COEFF * m - NS_CIVIL_MONTH_OFFSET) / 32 + NS_CIVIL_MONTH_COEFF = 979 + NS_CIVIL_MONTH_OFFSET = 2919 + NS_CIVIL_MONTH_DIVISOR = 32 + private_constant :NS_CIVIL_MONTH_COEFF, :NS_CIVIL_MONTH_OFFSET, :NS_CIVIL_MONTH_DIVISOR + + # Days from March 1 to December 31 (for Jan/Feb year adjustment) + NS_DAYS_BEFORE_NEW_YEAR = 306 + private_constant :NS_DAYS_BEFORE_NEW_YEAR + + # Safe bounds for Neri-Schneider algorithm to avoid integer overflow. + # These correspond to approximately years -1,000,000 to +1,000,000. + NS_JD_MIN = -364_000_000 + NS_JD_MAX = 538_000_000 + private_constant :NS_JD_MIN, :NS_JD_MAX + + JULIAN_EPOCH_DATE = "-4712-01-01" + JULIAN_EPOCH_DATETIME = "-4712-01-01T00:00:00+00:00" + JULIAN_EPOCH_DATETIME_RFC2822 = "Mon, 1 Jan -4712 00:00:00 +0000" + JULIAN_EPOCH_DATETIME_HTTPDATE = "Mon, 01 Jan -4712 00:00:00 GMT" + private_constant :JULIAN_EPOCH_DATE, :JULIAN_EPOCH_DATETIME, :JULIAN_EPOCH_DATETIME_RFC2822, :JULIAN_EPOCH_DATETIME_HTTPDATE + + JISX0301_ERA_INITIALS = 'mtshr' + JISX0301_DEFAULT_ERA = 'H' # obsolete + private_constant :JISX0301_ERA_INITIALS, :JISX0301_DEFAULT_ERA + + HAVE_ALPHA = 1 << 0 + HAVE_DIGIT = 1 << 1 + HAVE_DASH = 1 << 2 + HAVE_DOT = 1 << 3 + HAVE_SLASH = 1 << 4 + private_constant :HAVE_ALPHA, :HAVE_DIGIT, :HAVE_DASH, :HAVE_DOT, :HAVE_SLASH + + # C: default strftime format is US-ASCII + STRFTIME_DEFAULT_FMT = '%F'.encode(Encoding::US_ASCII) + private_constant :STRFTIME_DEFAULT_FMT + + # strftime spec categories + NUMERIC_SPECS = %w[Y C y m d j H I M S L N G g U W V u w s Q].freeze + SPACE_PAD_SPECS = %w[e k l].freeze + CHCASE_UPPER_SPECS = %w[A a B b h].freeze + CHCASE_LOWER_SPECS = %w[Z p].freeze + private_constant :NUMERIC_SPECS, :SPACE_PAD_SPECS, + :CHCASE_UPPER_SPECS, :CHCASE_LOWER_SPECS + + # strptime digit-consuming specs + NUM_PATTERN_SPECS = "CDdeFGgHIjkLlMmNQRrSsTUuVvWwXxYy" + private_constant :NUM_PATTERN_SPECS + + # Fragment completion table for DateTime parsing + COMPLETE_FRAGS_TABLE = [ + [:time, [:hour, :min, :sec].freeze], + [nil, [:jd].freeze], + [:ordinal, [:year, :yday, :hour, :min, :sec].freeze], + [:civil, [:year, :mon, :mday, :hour, :min, :sec].freeze], + [:commercial, [:cwyear, :cweek, :cwday, :hour, :min, :sec].freeze], + [:wday, [:wday, :hour, :min, :sec].freeze], + [:wnum0, [:year, :wnum0, :wday, :hour, :min, :sec].freeze], + [:wnum1, [:year, :wnum1, :wday, :hour, :min, :sec].freeze], + [nil, [:cwyear, :cweek, :wday, :hour, :min, :sec].freeze], + [nil, [:year, :wnum0, :cwday, :hour, :min, :sec].freeze], + [nil, [:year, :wnum1, :cwday, :hour, :min, :sec].freeze], + ].each { |a| a.freeze }.freeze + private_constant :COMPLETE_FRAGS_TABLE +end diff --git a/lib/date/core.rb b/lib/date/core.rb new file mode 100644 index 0000000..03b6d9c --- /dev/null +++ b/lib/date/core.rb @@ -0,0 +1,3693 @@ +# frozen_string_literal: true + +# Implementation of ruby/date/ext/date/date_core.c +class Date + include Comparable + + Error = Class.new(ArgumentError) + + # Initialize method + # 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) + y = year + m = month + d = day + sg = start + fr2 = 0 + + # argument type checking + if y + raise TypeError, "invalid year (not numeric)" unless year.is_a?(Numeric) + end + if m + raise TypeError, "invalid month (not numeric)" unless month.is_a?(Numeric) + end + if d + raise TypeError, "invalid day (not numeric)" unless day.is_a?(Numeric) + # Check if there is a decimal part. + d_trunc, fr = d_trunc_with_frac(d) + d = d_trunc + fr2 = fr if fr.nonzero? + end + + sg = self.class.send(:valid_sg, sg) + style = self.class.send(:guess_style, y, sg) + + if style < 0 + # gregorian calendar only + result = self.class.send(:valid_gregorian_p, y, m, d) + raise Error unless result + + nth, ry = self.class.send(:decode_year, y, -1) + rm = result[:rm] + rd = result[:rd] + + @nth = canon(nth) + @jd = 0 + @sg = sg + @year = ry + @month = rm + @day = rd + @has_jd = false + @has_civil = true + @df = nil + @sf = nil + @of = nil + else + # full validation + result = self.class.send(:valid_civil_p, y, m, d, sg) + raise Error unless result + + nth = result[:nth] + ry = result[:ry] + rm = result[:rm] + rd = result[:rd] + rjd = result[:rjd] + + @nth = canon(nth) + @jd = rjd + @sg = sg + @year = ry + @month = rm + @day = rd + @has_jd = true + @has_civil = true + @df = nil + @sf = nil + @of = nil + end + + # Add any decimal parts. + if fr2.nonzero? + new_date = self + fr2 + @nth = new_date.instance_variable_get(:@nth) + @jd = new_date.instance_variable_get(:@jd) + @sg = new_date.instance_variable_get(:@sg) + @year = new_date.instance_variable_get(:@year) + @month = new_date.instance_variable_get(:@month) + @day = new_date.instance_variable_get(:@day) + @has_jd = new_date.instance_variable_get(:@has_jd) + @has_civil = new_date.instance_variable_get(:@has_civil) + @df = new_date.instance_variable_get(:@df) + @sf = new_date.instance_variable_get(:@sf) + @of = new_date.instance_variable_get(:@of) + end + + self + end + + # Class methods + class << self + # Same as `Date.new`. + alias_method :civil, :new + + # 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 numeric?(year) + return false unless numeric?(month) + return false unless numeric?(day) + + result = valid_civil_sub(year, month, day, start, 0) + + !result.nil? + 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) + j = 0 + fr = 0 + sg = start + + sg = valid_sg(start) if start + + if jd + raise TypeError, "invalid jd (not numeric)" unless jd.is_a?(Numeric) + + j, fr = value_trunc(jd) + end + + nth, rjd = decode_jd(j) + + ret = d_simple_new_internal(nth, rjd, sg, 0, 0, 0, HAVE_JD) + + ret = ret + fr if fr.nonzero? + + ret + 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) + return false unless numeric?(jd) + + result = valid_jd_sub(jd, start, 0) + + !result.nil? + 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, "invalid year (not numeric)" unless numeric?(year) + + _, ry = decode_year(year, -1) + + c_gregorian_leap_p?(ry) + 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, "invalid year (not numeric)" unless numeric?(year) + + _, ry = decode_year(year, +1) + + c_julian_leap_p?(ry) + 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) + y = year + d = yday + fr2 = 0 + sg = start + + if y + raise TypeError, "invalid year (not numeric)" unless year.is_a?(Numeric) + end + if d + raise TypeError, "invalid yday (not numeric)" unless yday.is_a?(Numeric) + d_trunc, fr = value_trunc(d) + d = d_trunc + fr2 = fr if fr.nonzero? + end + + result = valid_ordinal_p(year, yday, start) + raise Error unless result + + nth = result[:nth] + rjd = result[:rjd] + + obj = d_simple_new_internal(nth, rjd, sg, 0, 0, 0, HAVE_JD) + + obj = obj + fr2 if fr2.nonzero? + + obj + 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 numeric?(year) + return false unless numeric?(day) + + result = valid_ordinal_sub(year, day, start, false) + + !result.nil? + 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) + y = cwyear + w = cweek + d = cwday + fr2 = 0 + sg = start + + if y + raise TypeError, "invalid year (not numeric)" unless cwyear.is_a?(Numeric) + end + if w + raise TypeError, "invalid cweek (not numeric)" unless cweek.is_a?(Numeric) + w = w.to_i + end + if d + raise TypeError, "invalid cwday (not numeric)" unless cwday.is_a?(Numeric) + d_trunc, fr = value_trunc(d) + d = d_trunc + fr2 = fr if fr.nonzero? + end + + sg = valid_sg(start) if start + + result = valid_commercial_p(y, w, d, sg) + raise Error unless result + + nth = result[:nth] + rjd = result[:rjd] + + obj = d_simple_new_internal(nth, rjd, sg, 0, 0, 0, HAVE_JD) + + obj = obj + fr2 if fr2.nonzero? + + obj + 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 numeric?(year) + return false unless numeric?(week) + return false unless numeric?(day) + + result = valid_commercial_sub(year, week, day, start, false) + + !result.nil? + 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) + begin + time = Time.now + rescue + raise SystemCallError, "time" + end + + begin + y = time.year + m = time.month + d = time.day + rescue + raise SystemCallError, "localtime" + end + + nth, ry, _, _ = decode_year(y, -1) + + obj = allocate + obj.instance_variable_set(:@nth, nth) + obj.instance_variable_set(:@year, ry) + obj.instance_variable_set(:@month, m) + obj.instance_variable_set(:@day, d) + obj.instance_variable_set(:@jd, nil) + obj.instance_variable_set(:@sg, GREGORIAN) + obj.instance_variable_set(:@has_jd, false) + obj.instance_variable_set(:@has_civil, true) + + if start != GREGORIAN + obj.instance_variable_set(:@sg, start) + if obj.instance_variable_get(:@has_jd) + obj.instance_variable_set(:@jd, nil) + obj.instance_variable_set(:@has_jd, false) + end + end + + obj + end + + # :nodoc: + # C: date_s__load — for Marshal format 1.4, 1.6, 1.8 (u: prefix) + def _load(s) + a = Marshal.load(s) + obj = allocate + obj.marshal_load(a) + obj + end + + private + + # Optimized: Gregorian date -> Julian Day Number + def gregorian_civil_to_jd(year, month, day) + # Shift epoch to March 1 of year 0 (Jan/Feb belong to previous year) + j = (month < 3) ? 1 : 0 + y0 = year - j + m0 = j == 1 ? month + 12 : month + d0 = day - 1 + + # Calculate year contribution with leap year correction + q1 = y0 / NS_YEARS_PER_CENTURY + yc = (NS_DAYS_IN_4_YEARS * y0) / 4 - q1 + (q1 / 4) + + # Calculate month contribution using integer arithmetic + mc = (NS_CIVIL_MONTH_COEFF * m0 - NS_CIVIL_MONTH_OFFSET) / NS_CIVIL_MONTH_DIVISOR + + # Combine and add epoch offset to get JDN + yc + mc + d0 + NS_EPOCH + end + + def julian_civil_to_jd(y, m, d) + # Traditional Julian calendar algorithm + y2 = y + m2 = m + + if m2 <= 2 + y2 -= 1 + m2 += 12 + end + + (365.25 * (y2 + 4716)).floor + (30.6001 * (m2 + 1)).floor + d - 1524 + end + + def validate_ordinal(year, yday, sg) + # Handling negative day of year + if yday < 0 + # Counting backwards from the end of the year + last_jd, _ = c_find_ldoy(year, sg) + return nil unless last_jd + + # Recalculate the total number of days in the year from the calculated JD + adjusted_jd = last_jd + yday + 1 + y, d = jd_to_ordinal(adjusted_jd, sg) + + # Invalid if the year does not match + return nil if y != year + + yday = d + end + + # Calculate jd from the day of the year + nth, ry, _, _ = decode_year(year, sg) + first_jd, ns = c_find_fdoy(ry, sg) + + return nil unless first_jd + + jd = first_jd + yday - 1 + + # Verify that the calculated jd actually belongs to the specified year + verify_y, verify_d = jd_to_ordinal(jd, sg) + return nil if verify_y != ry || verify_d != yday + + [nth, ry, yday, jd, ns] + end + + def extract_fraction(value) + if value.is_a?(Rational) || value.is_a?(Float) + int_part = value.floor + frac_part = value - int_part + [int_part, frac_part] + else + [value.to_i, 0] + end + end + + def jd_to_ordinal(jd, sg) + year, _, _ = jd_to_civil_internal(jd, sg) + first_jd, _ = c_find_fdoy(year, sg) + yday = jd - first_jd + 1 + + [year, yday] + end + + def validate_commercial(year, week, day, sg) + if day < 0 + day += 8 # -1 -> 7 (Sun), -7 -> 1 (Mon) + end + + return nil if day < 1 || day > 7 + + if week < 0 + next_year_jd, ns = commercial_to_jd_internal(year + 1, 1, 1, sg) + return nil unless next_year_jd + + adjusted_jd = next_year_jd + week * 7 + y2, w2, _ = jd_to_commercial_internal(adjusted_jd, sg) + + return nil if y2 != year + + week = w2 + end + + # Calculate jd from ISO week date + nth, ry, _, _ = decode_year(year, sg) + jd, ns = commercial_to_jd_internal(ry, week, day, sg) + + return nil unless jd + + verify_y, verify_w, verify_d = jd_to_commercial_internal(jd, sg) + return nil if verify_y != ry || verify_w != week || verify_d != day + + [nth, ry, week, day, jd, ns] + end + + def commercial_to_jd_internal(cwyear, cweek, cwday, sg) + # Calculating ISO week date(The week containing January 4 is week 1) + jan4_jd = gregorian_civil_to_jd(cwyear, 1, 4) + + # Day of the week on which January 4th falls + # (0 = Sun, 1 = Mon, ..., 6 = Sat) + jan4_wday = (jan4_jd + 1) % 7 + + # Monday of week 1 + week1_mon = jan4_jd - jan4_wday + 1 + + # jd for a specified weekday + jd = week1_mon + (cweek - 1) * 7 + (cwday - 1) + + # If before sg, it is the Julian calendar + ns = jd >= sg ? 1 : 0 + + [jd, ns] + end + + def jd_to_commercial_internal(jd, sg) + # get date from jd + year, _, _ = jd_to_civil_internal(jd, sg) + + # calculate jd for January 4 of that year + jan4_jd = gregorian_civil_to_jd(year, 1, 4) + jan4_wday = (jan4_jd + 1) % 7 + week1_mon = jan4_jd - jan4_wday + 1 + + # If jd is before the first week, it belongs to the previous year + if jd < week1_mon + year -= 1 + jan4_jd = gregorian_civil_to_jd(year, 1, 4) + jan4_wday = (jan4_jd + 1) % 7 + week1_mon = jan4_jd - jan4_wday + 1 + end + + # check the first week of the next year + next_jan4 = gregorian_civil_to_jd(year + 1, 1, 4) + next_jan4_wday = (next_jan4 + 1) % 7 + next_week1_mon = next_jan4 - next_jan4_wday + 1 + + if jd >= next_week1_mon + year += 1 + week1_mon = next_week1_mon + end + + # Calculate the week number + week = (jd - week1_mon) / 7 + 1 + + # week(1 = mon, ..., 7 = sun) + cwday = (jd + 1) % 7 + cwday = 7 if cwday.zero? + + [year, week, cwday] + end + + def jd_to_civil_internal(jd, sg) + # Does it overlap with jd_to_civil? + # Calculate the date from jd (using existing methods) + # simple version + r0 = jd - NS_EPOCH + + n1 = 4 * r0 + 3 + q1 = n1 / NS_DAYS_IN_400_YEARS + r1 = (n1 % NS_DAYS_IN_400_YEARS) / 4 + + n2 = 4 * r1 + 3 + u2 = NS_YEAR_MULTIPLIER * n2 + q2 = u2 >> 32 + r2 = (u2 & 0xFFFFFFFF) / NS_YEAR_MULTIPLIER / 4 + + n3 = NS_MONTH_COEFF * r2 + NS_MONTH_OFFSET + q3 = n3 >> 16 + r3 = (n3 & 0xFFFF) / NS_MONTH_COEFF + + y0 = NS_YEARS_PER_CENTURY * q1 + q2 + j = (r2 >= NS_DAYS_BEFORE_NEW_YEAR) ? 1 : 0 + + year = y0 + j + month = j == 1 ? q3 - 12 : q3 + day = r3 + 1 + + [year, month, day] + end + + def valid_civil_date?(year, month, day, sg) + return false if month < 1 || month > 12 + + if sg == GREGORIAN || sg < 0 + last_day = last_day_of_month_gregorian(year, month) + elsif sg == JULIAN || sg > 0 + last_day = last_day_of_month_julian(year, month) + else + # Calculate (calendar reform period - jd) and determine + jd = gregorian_civil_to_jd(year, month, day) + + if jd < sg + last_day = last_day_of_month_julian(year, month) + else + last_day = last_day_of_month_gregorian(year, month) + end + end + + return false if day < 1 || day > last_day + + true + end + + def last_day_of_month_gregorian(y, m) + return nil if m < 1 || m > 12 + + leap_index = gregorian_leap?(y) ? 1 : 0 + MONTH_DAYS[leap_index][m] + end + + def last_day_of_month_julian(y, m) + return nil if m < 1 || m > 12 + + leap_index = julian_leap?(y) ? 1 : 0 + MONTH_DAYS[leap_index][m] + end + + def civil_to_jd_with_check(year, month, day, sg) + return nil unless valid_civil_date?(year, month, day, sg) + + jd, ns = civil_to_jd(year, month, day, sg) + + [jd, ns] + end + + def civil_to_jd(year, month, day, sg) + if sg == GREGORIAN + jd = gregorian_civil_to_jd(year, month, day) + + return [jd, 1] + end + + jd = gregorian_civil_to_jd(year, month, day) + + if jd < sg + jd = julian_civil_to_jd(year, month, day) + ns = 0 + else + ns = 1 + end + + [jd, ns] + end + + def last_day_of_month_for_sg(year, month, sg) + last_day_of_month_gregorian(year, month) + end + + def validate_civil(year, month, day, sg) + month += 13 if month < 0 + return nil if month < 1 || month > 12 + + if day < 0 + last_day = last_day_of_month_gregorian(year, month) + return nil unless last_day + day = last_day + day + 1 + end + + last_day = last_day_of_month_gregorian(year, month) + return nil if day < 1 || day > last_day + + nth, ry = decode_year(year, -1) + + jd, ns = civil_to_jd_with_style(ry, month, day, sg) + + [nth, ry, month, day, jd, ns] + end + + def civil_to_jd_with_style(year, month, day, sg) + jd = gregorian_civil_to_jd(year, month, day) + + if jd < sg + jd = julian_civil_to_jd(year, month, day) + ns = 0 + else + ns = 1 + end + + [jd, ns] + end + + def convert_to_integer(value) + if value.respond_to?(:to_int) + value.to_int + elsif value.is_a?(Numeric) + value.to_i + else + value + end + end + + def numeric?(value) + value.is_a?(Numeric) || value.respond_to?(:to_int) + end + + def valid_civil_sub(year, month, day, start, need_jd) + year = convert_to_integer(year) + month = convert_to_integer(month) + day = convert_to_integer(day) + + start = valid_sg(start) + + return nil if month < 1 || month > 12 + + leap_year = start == JULIAN ? julian_leap?(year) : gregorian_leap?(year) + + days_in_month = [nil, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] + max_day = (month == 2 && leap_year) ? 29 : days_in_month[month] + + return nil if day < 1 || day > max_day + + need_jd ? civil_to_jd(year, month, day, start) : 0 + end + + def valid_sg(start) + unless c_valid_start_p(start) + warn "invalid start is ignored" + return 0 + end + + start + end + + def c_valid_start_p(start) + return false unless start.is_a?(Numeric) + + return false if start.respond_to?(:nan?) && start.nan? + + return true if start.respond_to?(:infinite?) && start.infinite? + + return false if start < REFORM_BEGIN_JD || start > REFORM_END_JD + + true + end + + def valid_jd_sub(jd, start, need_jd) + valid_sg(start) + + jd + end + + def valid_commercial_sub(year, week, day, start, need_jd) + week = convert_to_integer(week) + day = convert_to_integer(day) + + valid_sg(start) + + result = valid_commercial_p(year, week, day, start) + + return nil unless result + + return 0 unless need_jd + + encode_jd(result[:nth], result[:rjd]) + end + + def valid_commercial_p(year, week, day, start) + style = guess_style(year, start) + + if style.zero? + int_year = year.to_i + result = c_valid_commercial_p(int_year, week, day, start) + return nil unless result + + nth, rjd = decode_jd(result[:jd]) + + if f_zero_p?(nth) + ry = int_year + else + ns = result[:ns] + _, ry = decode_year(year, ns.nonzero? ? -1 : 1) + end + + { nth: nth, ry: ry, rw: result[:rw], rd: result[:rd], rjd: rjd, ns: result[:ns] } + else + nth, ry = decode_year(year, style) + result = c_valid_commercial_p(ry, week, day, style) + return nil unless result + + { nth: nth, ry: ry, rw: result[:rw], rd: result[:rd], rjd: result[:jd], ns: result[:ns] } + end + end + + def guess_style(year, sg) + return sg if sg.infinite? + return year >= 0 ? GREGORIAN : JULIAN unless year.is_a?(Integer) && year.abs < (1 << 62) + + int_year = year.to_i + if int_year < REFORM_BEGIN_YEAR + JULIAN + elsif int_year > REFORM_END_YEAR + GREGORIAN + else + 0 + end + end + + def c_valid_commercial_p(year, week, day, sg) + day += 8 if day < 0 + + if week < 0 + rjd2, _ = c_commercial_to_jd(year + 1, 1, 1, sg) + ry2, rw2, _ = c_jd_to_commercial(rjd2 + week * 7, sg) + return nil if ry2 != year + + week = rw2 + end + + rjd, ns = c_commercial_to_jd(year, week, day, sg) + ry2, rw, rd = c_jd_to_commercial(rjd, sg) + + return nil if year != ry2 || week != rw || day != rd + + { jd: rjd, ns: ns, rw: rw, rd: rd } + end + + def c_commercial_to_jd(year, week, day, sg) + rjd2, _ = c_find_fdoy(year, sg) + rjd2 += 3 + + # Calcurate ISO week number. + rjd = (rjd2 - ((rjd2 - 1 + 1) % 7)) + 7 * (week - 1) + (day - 1) + ns = (rjd < sg) ? 0 : 1 + + [rjd, ns] + end + + def c_jd_to_commercial(jd, sg) + ry2, _, _ = c_jd_to_civil(jd - 3, sg) + a = ry2 + + rjd2, _ = c_commercial_to_jd(a + 1, 1, 1, sg) + if jd >= rjd2 + ry = a + 1 + else + rjd2, _ = c_commercial_to_jd(a, 1, 1, sg) + ry = a + end + + rw = 1 + (jd - rjd2) / 7 + rd = (jd + 1) % 7 + rd = 7 if rd.zero? + + [ry, rw, rd] + end + + def c_find_fdoy(year, sg) + if c_gregorian_only_p?(sg) + jd = c_gregorian_fdoy(year) + + return [jd, 1] + end + + # Keep existing loop for Julian/reform period + (1..30).each do |d| + result = c_valid_civil_p(year, 1, d, sg) + + return [result[:jd], result[:ns]] if result + end + + [nil, nil] + end + + def c_find_ldom(year, month, sg) + if c_gregorian_only_p?(sg) + jd = c_gregorian_ldom_jd(year, month) + + return [jd, 1] + end + + # Keep existing loop for Julian/reform period + (0..29).each do |i| + result = c_valid_civil_p(year, month, 31 - i, sg) + return [result[:jd], result[:ns]] if result + end + + nil + end + + def c_gregorian_fdoy(year) + c_gregorian_civil_to_jd(year, 1, 1) + end + + def c_jd_to_civil(jd, sg) + # Fast path: pure Gregorian or date after switchover, within safe range + if (c_gregorian_only_p?(sg) || jd >= sg) && ns_jd_in_range(jd) + return c_gregorian_jd_to_civil(jd) + end + + # Original algorithm for Julian calendar or extreme dates + if jd < sg + a = jd + else + x = ((jd - 1867216.25) / 36524.25).floor + a = jd + 1 + x - (x / 4.0).floor + end + + b = a + 1524 + c = ((b - 122.1) / 365.25).floor + d = (365.25 * c).floor + e = ((b - d) / 30.6001).floor + dom = b - d - (30.6001 * e).floor + + if e <= 13 + m = e - 1 + y = c - 4716 + else + m = e - 13 + y = c - 4715 + end + + [y.to_i, m.to_i, dom.to_i] + end + + # Optimized: Julian Day Number -> Gregorian date + def c_gregorian_jd_to_civil(jd) + # The argument jd of c_gregorian_jd_to_civil implemented in C is of type int, + # so it is converted to Integer. + jd = jd.to_i unless jd.is_a?(Integer) + + # Convert JDN to rata die (March 1, Year 0 epoch) + r0 = jd - NS_EPOCH + + # Extract century and day within 400-year cycle + # Use Euclidean (floor) division for negative values + n1 = 4 * r0 + 3 + q1 = n1 / NS_DAYS_IN_400_YEARS + r1 = (n1 % NS_DAYS_IN_400_YEARS) / 4 + + # Calculate year within century and day of year + n2 = 4 * r1 + 3 + # Use 64-bit arithmetic to avoid overflow + u2 = NS_YEAR_MULTIPLIER * n2 + q2 = u2 >> 32 + r2 = (u2 & 0xFFFFFFFF) / NS_YEAR_MULTIPLIER / 4 + + # Calculate month and day using integer arithmetic + n3 = NS_MONTH_COEFF * r2 + NS_MONTH_OFFSET + q3 = n3 >> 16 + r3 = (n3 & 0xFFFF) / NS_MONTH_COEFF + + # Combine century and year + y0 = NS_YEARS_PER_CENTURY * q1 + q2 + + # Adjust for January/February (shift from fiscal year) + j = (r2 >= NS_DAYS_BEFORE_NEW_YEAR) ? 1 : 0 + + ry = y0 + j + rm = j.nonzero? ? q3 - 12 : q3 + rd = r3 + 1 + + [ry, rm, rd] + end + + def c_gregorian_civil_to_jd(year, month, day) + j = (month < 3) ? 1 : 0 + y0 = year - j + m0 = j.nonzero? ? month + 12 : month + d0 = day - 1 + + q1 = y0 / 100 + yc = (NS_DAYS_IN_4_YEARS * y0) / 4 - q1 + q1 / 4 + + mc = (NS_DAYS_BEFORE_NEW_YEAR * m0 - 914) / 10 + + yc + mc + d0 + NS_EPOCH + end + + def valid_civil_p(y, m, d, sg) + style = guess_style(y, sg) + + if style.zero? + # If year is a Fixnum + int_year = y.to_i + + # Validate with c_valid_civil_p + result = c_valid_civil_p(int_year, m, d, sg) + return nil unless result + + # decode_jd + nth, rjd = decode_jd(result[:jd]) + + if f_zero_p?(nth) + ry = int_year + else + ns = result[:ns] + _, ry = decode_year(y, ns.nonzero? ? -1 : 1) + end + + return { nth: nth, ry: ry, rm: result[:rm], rd: result[:rd], rjd: rjd, ns: result[:ns] } + else + # If year is a large number + nth, ry = decode_year(y, style) + + result = style < 0 ? c_valid_gregorian_p(ry, m, d) : result = c_valid_julian_p(ry, m, d) + return nil unless result + + # Calculate JD from civil + rjd, ns = c_civil_to_jd(ry, result[:rm], result[:rd], style) + + return { nth: nth, ry: ry, rm: result[:rm], rd: result[:rd], rjd: rjd, ns: ns } + end + end + + def c_valid_civil_p(year, month, day, sg) + month += 13 if month < 0 + return nil if month < 1 || month > 12 + + rd = day + if rd < 0 + result = c_find_ldom(year, month, sg) + return nil unless result + + rjd2, _ = result + ry2, rm2, rd2 = c_jd_to_civil(rjd2 + rd + 1, sg) + return nil if ry2 != year || rm2 != month + + rd = rd2 + end + + rjd, ns = c_civil_to_jd(year, month, rd, sg) + ry2, rm2, rd2 = c_jd_to_civil(rjd, sg) + + return nil if ry2 != year || rm2 != month || rd2 != rd + + { jd: rjd, ns: ns, rm: rm2, rd: rd } + end + + def c_gregorian_ldom_jd(year, month) + last_day = c_gregorian_last_day_of_month(year, month) + c_gregorian_civil_to_jd(year, month, last_day) + end + + def c_gregorian_last_day_of_month(year, month) + days_in_month = [nil, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] + + if month == 2 && gregorian_leap?(year) + 29 + else + days_in_month[month] + end + end + + def c_civil_to_jd(year, month, day, sg) + if c_gregorian_only_p?(sg) + jd = c_gregorian_civil_to_jd(year, month, day) + + return [jd, 1] + end + + # Calculate Gregorian JD using optimized algorithm + jd = c_gregorian_civil_to_jd(year, month, day) + + if jd < sg + y2 = year + m2 = month + if m2 <= 2 + y2 -= 1 + m2 += 12 + end + jd = (365.25 * (y2 + 4716)).floor + (30.6001 * (m2 + 1)).floor + day - 1524 + ns = 0 + else + ns = 1 + end + + [jd, ns] + end + + def decode_jd(jd) + nth = jd / CM_PERIOD + rjd = f_zero_p?(nth) ? jd : jd % CM_PERIOD + + [nth, rjd] + end + + def encode_jd(nth, rjd) + f_zero_p?(nth) ? rjd : nth * CM_PERIOD + rjd + end + + def decode_year(year, style) + period = (style < 0) ? CM_PERIOD_GCY : CM_PERIOD_JCY + + if year.is_a?(Integer) && year.abs < (1 << 30) + shifted = year + 4712 + nth = shifted / period + + shifted = shifted % period if f_nonzero_p?(nth) + + ry = shifted - 4712 + else + shifted = year + 4712 + nth = shifted / period + + shifted = shifted % period if f_nonzero_p?(nth) + + ry = shifted.to_i - 4712 + end + + [nth, ry] + end + + # Check if using pure Gregorian calendar (sg == -Infinity) + def c_gregorian_only_p?(sg) + sg.infinite? && sg < 0 + end + + def valid_ordinal_sub(year, day, start, need_jd) + day = convert_to_integer(day) + + valid_sg(start) + + result = valid_ordinal_p(year, day, start) + + return nil unless result + + return 0 unless need_jd + + encode_jd(result[:nth], result[:rjd]) + end + + def valid_ordinal_p(year, day, start) + style = guess_style(year, start) + + if style.zero? + int_year = year.to_i + result = c_valid_ordinal_p(int_year, day, start) + + return nil unless result + + nth, rjd = decode_jd(result[:jd]) + + if f_zero_p?(nth) + ry = int_year + else + ns = result[:ns] + _, ry = decode_year(year, ns.nonzero? ? -1 : 1) + end + + return { nth: nth, ry: ry, rd: result[:rd], rjd: rjd, ns: result[:ns] } + else + nth, ry = decode_year(year, style) + result = c_valid_ordinal_p(ry, day, style) + return nil unless result + + return { nth: nth, ry: ry, rd: result[:rd], rjd: result[:jd], ns: result[:ns] } + end + end + + def c_valid_ordinal_p(year, day, sg) + rd = day + if rd < 0 + result = c_find_ldoy(year, sg) + return nil unless result + + rjd2, _ = result + ry2, rd2 = c_jd_to_ordinal(rjd2 + rd + 1, sg) + return nil if ry2 != year + + rd = rd2 + end + + rjd, ns = c_ordinal_to_jd(year, rd, sg) + ry2, rd2 = c_jd_to_ordinal(rjd, sg) + + return nil if ry2 != year || rd2 != rd + + { jd: rjd, ns: ns, rd: rd } + end + + def c_find_ldoy(year, sg) + if c_gregorian_only_p?(sg) + jd = c_gregorian_ldoy(year) + + return [jd, 1] + end + + # Keep existing loop for Julian/reform period + (0..29).each do |i| + result = c_valid_civil_p(year, 12, 31 - i, sg) + + return [result[:jd], result[:ns]] if result + end + + nil + end + + # O(1) last day of year for Gregorian calendar + def c_gregorian_ldoy(year) + c_gregorian_civil_to_jd(year, 12, 31) + end + + def c_jd_to_ordinal(jd, sg) + ry, _, _ = c_jd_to_civil(jd, sg) + rjd, _ = c_find_fdoy(ry, sg) + + rd = (jd - rjd) + 1 + + [ry, rd] + end + + def c_ordinal_to_jd(year, day, sg) + rjd, _ = c_find_fdoy(year, sg) + rjd += day - 1 + ns = (rjd < sg) ? 0 : 1 + + [rjd, ns] + end + + def f_zero_p?(x) + case x + when Integer + x.zero? + when Rational + x.numerator == 0 + else + x == 0 + end + end + + def f_nonzero_p?(x) + !f_zero_p?(x) + end + + def c_gregorian_leap_p?(year) + !!(((year % 4).zero? && (year % 100).nonzero?) || (year % 400).zero?) + end + + def c_julian_leap_p?(year) + (year % 4).zero? + end + + def new_with_jd(nth, jd, start) + new_with_jd_and_time(nth, jd, nil, nil, nil, start) + end + + def new_with_jd_and_time(nth, jd, df, sf, of, start) + obj = allocate + obj.instance_variable_set(:@nth, nth) + obj.instance_variable_set(:@jd, jd) + obj.instance_variable_set(:@sg, start) + obj.instance_variable_set(:@df, df) + obj.instance_variable_set(:@sf, sf) + obj.instance_variable_set(:@of, of) + obj.instance_variable_set(:@year, nil) + obj.instance_variable_set(:@month, nil) + obj.instance_variable_set(:@day, nil) + obj.instance_variable_set(:@has_jd, true) + obj.instance_variable_set(:@has_civil, false) + + obj + end + + def valid_gregorian_p(y, m, d) + decode_year(y, -1) + + c_valid_gregorian_p(y, m, d) + end + + def c_valid_gregorian_p(y, m, d) + m += 13 if m < 0 + return nil if m < 1 || m > 12 + + last = c_gregorian_last_day_of_month(y, m) + d = last + d + 1 if d < 0 + return nil if d < 1 || d > last + + { rm: m, rd: d } + end + + def c_valid_julian_p(y, m, d) + m += 13 if m < 0 + return nil if m < 1 || m > 12 + + last = c_julian_last_day_of_month(y, m) + d = last + d + 1 if d < 0 + return nil if d < 1 || d > last + + { rm: m, rd: d } + end + + def c_julian_last_day_of_month(y, m) + raise Error unless m >= 1 && m <= 12 + + MONTH_DAYS[julian_leap?(y) ? 1 : 0][m] + end + + # Create a simple Date object. + def d_lite_s_alloc_simple + obj = allocate + obj.instance_variable_set(:@nth, 0) + obj.instance_variable_set(:@jd, 0) + obj.instance_variable_set(:@sg, DEFAULT_SG) + obj.instance_variable_set(:@df, nil) + obj.instance_variable_set(:@sf, nil) + obj.instance_variable_set(:@of, nil) + obj.instance_variable_set(:@year, nil) + obj.instance_variable_set(:@month, nil) + obj.instance_variable_set(:@day, nil) + obj.instance_variable_set(:@has_jd, true) + obj.instance_variable_set(:@has_civil, false) + + obj + end + + # Create a complex Date object. + def d_lite_s_alloc_complex + obj = allocate + obj.instance_variable_set(:@nth, 0) + obj.instance_variable_set(:@jd, 0) + obj.instance_variable_set(:@sg, DEFAULT_SG) + obj.instance_variable_set(:@df, nil) + obj.instance_variable_set(:@sf, nil) + obj.instance_variable_set(:@of, nil) + obj.instance_variable_set(:@year, nil) + obj.instance_variable_set(:@month, nil) + obj.instance_variable_set(:@day, nil) + obj.instance_variable_set(:@hour, nil) + obj.instance_variable_set(:@min, nil) + obj.instance_variable_set(:@sec, nil) + obj.instance_variable_set(:@has_jd, true) + obj.instance_variable_set(:@has_civil, false) + + obj + end + + def value_trunc(value) + if value.is_a?(Integer) + [value, 0] + elsif value.is_a?(Float) || value.is_a?(Rational) + trunc = value.truncate + frac = value - trunc + + [trunc, frac] + else + [value.to_i, 0] + end + end + + def d_simple_new_internal(nth, jd, sg, year, mon, mday, flags) + obj = allocate + obj.instance_variable_set(:@nth, canon(nth)) + obj.instance_variable_set(:@jd, jd) + obj.instance_variable_set(:@sg, sg) + obj.instance_variable_set(:@year, year) + obj.instance_variable_set(:@month, mon) + obj.instance_variable_set(:@day, mday) + obj.instance_variable_set(:@has_jd, (flags & HAVE_JD).nonzero?) + obj.instance_variable_set(:@has_civil, (flags & HAVE_CIVIL).nonzero?) + obj.instance_variable_set(:@df, nil) + obj.instance_variable_set(:@sf, nil) + obj.instance_variable_set(:@of, nil) + + obj + end + + def canon(x) + x.is_a?(Rational) && x.denominator == 1 ? x.numerator : x + end + + def ns_jd_in_range(jd) + jd >= NS_JD_MIN && jd <= NS_JD_MAX + end + + def check_limit(str, limit) + unless str.is_a?(String) + begin + str = str.to_str + rescue NoMethodError + raise TypeError, "no implicit conversion of #{str.class} into String" + end + end + + if limit && str.length > limit + raise ArgumentError, "string length (#{str.length}) exceeds the limit #{limit}" + end + + str + end + end + + # Instance methods + + # call-seq: + # year -> integer + # + # Returns the year: + # + # Date.new(2001, 2, 3).year # => 2001 + # (Date.new(1, 1, 1) - 1).year # => 0 + def year + m_real_year + end + + # call-seq: + # mon -> integer + # + # Returns the month in range (1..12): + # + # Date.new(2001, 2, 3).mon # => 2 + def month + m_mon + end + alias mon month + + def day + m_mday + 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 + m_real_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: + # 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 + m_canonicalize_jd + other.send(:m_canonicalize_jd) + + a_nth = m_nth + b_nth = other.send(:m_nth) + + cmp = a_nth <=> b_nth + return cmp if cmp.nonzero? + + a_jd = m_jd + b_jd = other.send(:m_jd) + + cmp = a_jd <=> b_jd + return cmp if cmp.nonzero? + + a_df = m_df + b_df = other.send(:m_df) + + cmp = a_df <=> b_df + return cmp if cmp.nonzero? + + a_sf = m_sf + b_sf = other.send(:m_sf) + + a_sf <=> b_sf + when Numeric + ajd <=> other + else + begin + l, r = other.coerce(self) + l <=> r + rescue NoMethodError + nil + end + end + 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) + return equal_gen(other) unless other.is_a?(Date) + + # Call equal_gen even if the Gregorian calendars do not match. + return equal_gen(other) unless m_gregorian_p? == other.send(:m_gregorian_p?) + + m_canonicalize_jd + other.send(:m_canonicalize_jd) + + a_nth = m_nth + b_nth = other.send(:m_nth) + a_jd = m_local_jd + b_jd = other.send(:m_local_jd) + + a_nth == b_nth && a_jd == b_jd + 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) + # Calculate years and months + t = m_real_year * 12 + (m_mon - 1) + n + + if t.is_a?(Integer) && t.abs < (1 << 62) + # Fixnum + y = t / 12 + m = (t % 12) + 1 + else + # Bignum + y = t.div(12) + m = (t % 12).to_i + 1 + end + + d = m_mday + sg = m_sg + + # Decrement days until a valid date. + result = nil + loop do + result = self.class.send(:valid_civil_p, y, m, d, sg) + break if result + + d -= 1 + raise Error if d < 1 + end + + nth = result[:nth] + rjd = result[:rjd] + rjd2 = self.class.send(:encode_jd, nth, rjd) + + self + (rjd2 - m_real_local_jd) + 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) + raise TypeError, "expected numeric" unless n.is_a?(Numeric) + + self >> (-n) + end + + def ==(other) # :nodoc: + return false unless other.is_a?(Date) + + m_canonicalize_jd + other.send(:m_canonicalize_jd) + + m_nth == other.send(:m_nth) && + m_jd == other.send(:m_jd) && + m_df == other.send(:m_df) && + m_sf == other.send(:m_sf) + end + + def eql?(other) # :nodoc: + return false unless other.is_a?(Date) + + m_canonicalize_jd + other.send(:m_canonicalize_jd) + + m_nth == other.send(:m_nth) && + m_jd == other.send(:m_jd) && + @sg == other.instance_variable_get(:@sg) + end + + def hash # :nodoc: + m_canonicalize_jd + [m_nth, m_jd, @sg].hash + end + + # 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 + nth = m_nth + jd = m_jd + + if (other / CM_PERIOD).nonzero? + nth = nth + (other / CM_PERIOD) + other = other % CM_PERIOD + end + + if other.nonzero? + jd = jd + other + nth, jd = canonicalize_jd(nth, jd) + end + + if simple_dat_p? + self.class.send(:new_with_jd, nth, jd, @sg) + else + self.class.send(:new_with_jd_and_time, nth, jd, @df || 0, @sf || 0, @of || 0, @sg) + end + when Float + s = other >= 0 ? 1 : -1 + o = other.abs + + tmp, o = o.divmod(1.0) + + if (tmp / CM_PERIOD).floor.zero? + nth = 0 + jd = tmp.to_i + else + i, f = (tmp / CM_PERIOD).divmod(1.0) + nth = i.floor + jd = (f * CM_PERIOD).to_i + end + + o *= DAY_IN_SECONDS + df, o = o.divmod(1.0) + df = df.to_i + o *= SECOND_IN_NANOSECONDS + sf = o.round + + if s < 0 + jd = -jd + df = -df + sf = -sf + end + + if sf.nonzero? + sf = 0 + sf + if sf < 0 + df -= 1 + sf += SECOND_IN_NANOSECONDS + elsif sf >= SECOND_IN_NANOSECONDS + df += 1 + sf -= SECOND_IN_NANOSECONDS + end + end + + if df.nonzero? + df = 0 + df + if df < 0 + jd -= 1 + df += DAY_IN_SECONDS + elsif df >= DAY_IN_SECONDS + jd += 1 + df -= DAY_IN_SECONDS + end + end + + if jd.nonzero? + jd = m_jd + jd + nth, jd = canonicalize_jd(nth, jd) + else + jd = m_jd + end + + nth = nth.nonzero? ? @nth + nth : @nth + + if df.zero? && sf.zero? && (@of.nil? || @of.zero?) + self.class.send(:new_with_jd, nth, jd, @sg) + else + self.class.send(:new_with_jd_and_time, nth, jd, df, sf, @of || 0, @sg) + end + when Rational + return self + other.numerator if other.denominator == 1 + + s = other >= 0 ? 1 : -1 + other = other.abs + + nth = other.div(CM_PERIOD) + t = other % CM_PERIOD + + jd = t.div(1).to_i + t = t % 1 + + t = t * DAY_IN_SECONDS + df = t.div(1).to_i + t = t % 1 + + sf = t * SECOND_IN_NANOSECONDS + + if s < 0 + nth = -nth + jd = -jd + df = -df + sf = -sf + end + + if sf.nonzero? + sf = (@sf || 0) + sf + if sf < 0 + df -= 1 + sf += SECOND_IN_NANOSECONDS + elsif sf >= SECOND_IN_NANOSECONDS + df += 1 + sf -= SECOND_IN_NANOSECONDS + end + else + sf = @sf || 0 + end + + if df.nonzero? + df = (@df || 0) + df + if df < 0 + jd -= 1 + df += DAY_IN_SECONDS + elsif df >= DAY_IN_SECONDS + jd += 1 + df -= DAY_IN_SECONDS + end + else + df = @df || 0 + end + + if jd.nonzero? + jd = m_jd + jd + nth, jd = canonicalize_jd(nth, jd) + else + jd = m_jd + end + + nth = nth.nonzero? ? @nth + nth : @nth + + if df.zero? && sf.zero? + self.class.send(:new_with_jd, nth, jd, @sg) + else + self.class.send(:new_with_jd_and_time, nth, jd, df, sf, @of || 0, @sg) + end + else + raise TypeError, "expected numeric" unless other.is_a?(Numeric) + + other = other.to_r + raise TypeError, "expected numeric" unless other.is_a?(Rational) + + self + other + 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) + return minus_dd(other) if other.is_a?(Date) + + raise TypeError, "expected numeric" unless other.is_a?(Numeric) + + # Add a negative value for numbers. + # Works with all types: Integer, Float, Rational, Bignum, etc. + self + (-other) + end + + # call-seq: + # gregorian -> new_date + # + # Equivalent to Date#new_start with argument Date::GREGORIAN. + def gregorian + dup_obj_with_new_start(GREGORIAN) + 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? + m_gregorian_p? + end + + # call-seq: + # italy -> new_date + # + # Equivalent to Date#new_start with argument Date::ITALY. + def italy + dup_obj_with_new_start(ITALY) + end + + # call-seq: + # england -> new_date + # + # Equivalent to Date#new_start with argument Date::ENGLAND. + def england + dup_obj_with_new_start(ENGLAND) + end + + # call-seq: + # julian -> new_date + # + # Equivalent to Date#new_start with argument Date::JULIAN. + def julian + dup_obj_with_new_start(JULIAN) + 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? + m_julian_p? + 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 + m_real_local_jd - 2299160 + 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? + if gregorian? + # For the Gregorian calendar, get m_year to determine if it is a leap year. + y = m_year + + return self.class.send(:c_gregorian_leap_p?, y) + end + + # For the Julian calendar, calculate JD for March 1st. + y = m_year + sg = m_virtual_sg + rjd, _ = self.class.send(:c_civil_to_jd, y, 3, 1, sg) + + # Get the date of the day before March 1st (the last day of February). + _, _, rd = self.class.send(:c_jd_to_civil, rjd - 1, sg) + + # If February 29th exists, it is a leap year. + rd == 29 + 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) + sg = start ? val2sg(start) : DEFAULT_SG + + dup_obj_with_new_start(sg) + 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 + next_day + 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: + # sunday? -> true or false + # + # Returns +true+ if +self+ is a Sunday, +false+ otherwise. + def sunday? + m_wday.zero? + end + + # call-seq: + # monday? -> true or false + # + # Returns +true+ if +self+ is a Monday, +false+ otherwise. + def monday? + m_wday == 1 + end + + # call-seq: + # tuesday? -> true or false + # + # Returns +true+ if +self+ is a Tuesday, +false+ otherwise. + def tuesday? + m_wday == 2 + end + + # call-seq: + # wednesday? -> true or false + # + # Returns +true+ if +self+ is a Wednesday, +false+ otherwise. + def wednesday? + m_wday == 3 + end + + # call-seq: + # thursday? -> true or false + # + # Returns +true+ if +self+ is a Thursday, +false+ otherwise. + def thursday? + m_wday == 4 + end + + # call-seq: + # friday? -> true or false + # + # Returns +true+ if +self+ is a Friday, +false+ otherwise. + def friday? + m_wday == 5 + end + + # call-seq: + # saturday? -> true or false + # + # Returns +true+ if +self+ is a Saturday, +false+ otherwise. + def saturday? + m_wday == 6 + 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 + m_wday + end + + # call-seq: + # yday -> integer + # + # Returns the day of the year, in range (1..366): + # + # Date.new(2001, 2, 3).yday # => 34 + def yday + m_yday + 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.nil? + return { + year: year, + month: month, + day: day, + yday: yday, + wday: wday + } + end + + raise TypeError, "wrong argument type #{keys.class} (expected Array or nil)" unless keys.is_a?(Array) + + h = {} + keys.each do |key| + case key + when :year then h[:year] = year + when :month then h[:month] = month + when :day then h[:day] = day + when :yday then h[:yday] = yday + when :wday then h[:wday] = wday + when :zone then h[:zone] = zone + end + end + h + 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) + raise ArgumentError, "step must be numeric" unless step.respond_to?(:<=>) + + return to_enum(:step, limit, step) unless block_given? + + date = self + cmp = step <=> 0 + + raise ArgumentError, "step must be numeric" if cmp.nil? + + case cmp + when -1 + # If step is negative (reverse order) + while (date <=> limit) >= 0 + yield date + date = date + step + end + when 0 + # If step is 0 (infinite loop) + loop do + yield date + end + else + # If step is positive (forward direction) + while (date <=> limit) <= 0 + yield date + date = date + step + end + end + + self + end + + # call-seq: + # upto(max){|date| ... } -> self + # + # Equivalent to #step with arguments +max+ and +1+. + def upto(max) + return to_enum(:upto, max) unless block_given? + + date = self + + while (date <=> max) <= 0 + yield date + date = date + 1 + end + + self + end + + # call-seq: + # downto(min){|date| ... } -> self + # + # Equivalent to #step with arguments +min+ and -1. + def downto(min) + return to_enum(:downto, min) unless block_given? + + date = self + + while (date <=> min) >= 0 + yield date + date = date - 1 + end + + self + 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 + simple_dat_p? ? 0 : m_fr + 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 + m_real_local_jd - 2_400_001 + 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 + m_real_cwyear + end + + # call-seq: + # cweek -> integer + # + # Returns commercial-date week index for +self+ + # (see Date.commercial): + # + # Date.new(2001, 2, 3).cweek # => 5 + def cweek + m_cweek + 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 + m_cwday + 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 + # Julian calendar converted to Gregorian calendar. + date = m_julian_p? ? gregorian : self + + # Create a Time object using Time.local. + Time.local( + date.send(:m_real_year), + date.send(:m_mon), + date.send(:m_mday) + ) + end + + # call-seq: + # to_date -> self + # + # Returns +self+. + def to_date + self + end + + # call-seq: + # to_datetime -> datetime + # + # Returns a DateTime whose value is the same as +self+. + def to_datetime + # Use internal constructor to bypass validation (reform-gap safety) + nth, ry = self.class.send(:decode_year, year, -1) + rjd, _ = self.class.send(:c_civil_to_jd, ry, month, day, Date::GREGORIAN) + obj = DateTime.send(:new_with_jd_and_time, nth, rjd, 0, 0, 0, Date::GREGORIAN) + obj.send(:set_sg, start) + obj + 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 + m_ajd + 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 + m_amjd + end + + # :nodoc: + # C: d_lite_initialize_copy + def initialize_copy(other) + unless other.is_a?(Date) + raise TypeError, "initialize_copy should take same class object" + end + @nth = other.instance_variable_get(:@nth) + @jd = other.instance_variable_get(:@jd) + @sg = other.instance_variable_get(:@sg) + @df = other.instance_variable_get(:@df) + @sf = other.instance_variable_get(:@sf) + @of = other.instance_variable_get(:@of) + @year = other.instance_variable_get(:@year) + @month = other.instance_variable_get(:@month) + @day = other.instance_variable_get(:@day) + @has_jd = other.instance_variable_get(:@has_jd) + @has_civil = other.instance_variable_get(:@has_civil) + self + end + + # :nodoc: + def marshal_dump + [ + m_nth, + m_jd, + m_df, + m_sf, + m_of, + @sg + ] + end + + # :nodoc: + # C: d_lite_marshal_load + # Supports 3 historical formats: + # 2 elements (1.4/1.6): [jd_like, sg_or_bool] + # 3 elements (1.8/1.9.2): [ajd, of, sg] + # 6 elements (current): [nth, jd, df, sf, of, sg] + def marshal_load(array) + raise TypeError, "expected an array" unless array.is_a?(Array) + + case array.size + when 2 # Ruby 1.4/1.6 + # C: ajd = f_sub(a[0], half_days_in_day); vof = 0; vsg = a[1] + ajd = array[0] - Rational(1, 2) + vof = 0 + vsg = array[1] + unless vsg.is_a?(Numeric) + vsg = vsg ? -Float::INFINITY : Float::INFINITY + end + nth, jd, df, sf, of_sec, sg = old_to_new(ajd, vof, vsg) + + when 3 # Ruby 1.8 / 1.9.2 + ajd = array[0] + vof = array[1] + vsg = array[2] + nth, jd, df, sf, of_sec, sg = old_to_new(ajd, vof, vsg) + + when 6 # Current format + nth, jd, df, sf, of_sec, sg = array + + else + raise TypeError, "invalid size" + end + + @nth = nth + @jd = jd + @df = (!df || df == 0) ? nil : df + @sf = (!sf || sf == 0) ? nil : sf + @of = (!of_sec || of_sec == 0) ? nil : of_sec + @sg = sg + + @has_jd = true + @has_civil = false + @year = nil + @month = nil + @day = nil + end + + # 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 + strftime('%a %b %e %H:%M:%S %Y') + 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 + strftime('%Y-%m-%d') + 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 + strftime('%Y-%m-%dT%H:%M:%S%:z') + 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 + strftime('%a, %-d %b %Y %T %z') + 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 + # For Date objects, offset is always 0, so we can directly call strftime + strftime('%a, %d %b %Y %T GMT') + 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 + jd = m_real_local_jd + y = m_real_year + + fmt = jisx0301_date_format(jd, y) + strftime(fmt) + end + + def to_s + s = sprintf("%04d-%02d-%02d", year, month, day) + s.force_encoding(Encoding::US_ASCII) + end + + # call-seq: + # inspect -> string + # + # Returns a string representation of +self+: + # + # Date.new(2001, 2, 3).inspect + # # => "#" + def inspect + s = if simple_dat_p? + inspect_raw + else + # In the case of complex, time information is also displayed. + strftime("%Y-%m-%d %H:%M:%S") + end + s.force_encoding(Encoding::US_ASCII) if s.ascii_only? + s + end + + # override + def freeze + # Force lazy computation before freezing so Ractor-shared objects work. + if simple_dat_p? + get_s_jd + get_s_civil + canonicalize_s_jd + else + get_c_jd + get_c_civil + get_c_df + get_c_time + canonicalize_c_jd + end + super + end + + private + + def inspect_raw + # If @sg is infinity + if @sg.infinite? + if @sg < 0 + return "#" + else + return "#" + end + end + + date_str = strftime("%Y-%m-%d") + jd_val = @jd || 0 + sg_val = @sg.infinite? ? (@sg < 0 ? "Inf" : "-Inf") : @sg.to_i + + "#" + end + + def valid_civil?(y, m, d) + return false if m < 1 || m > 12 + + last = last_day_of_month(y, m) + d >= 1 && d <= last + end + + def last_day_of_month(y, m) + last_day_of_month_gregorian(y, m) + end + + def civil_to_jd(y, m, d, sg) + self.class.send(:gregorian_civil_to_jd, y, m, d) + end + + def jd_to_civil(jd, sg) + decode_jd(jd) + self.class.send(:c_jd_to_civil, jd, sg) + end + + def extract_fraction(value) + self.class.send(:extract_fraction, value) + end + + def decode_year(year, style) + self.class.send(:decode_year, year, style) + end + + def valid_gregorian?(y, m, d) + return false if m < 1 || m > 12 + + # Handling negative months and days + m = m + 13 if m < 0 + return false if m < 1 || m > 12 + + last_day = last_day_of_month_gregorian(y, m) + d = last_day + d + 1 if d < 0 + + d >= 1 && d <= last_day + end + + def add_with_fraction(n) + int_part = n.floor + frac_part = n - int_part + + result = add_days(int_part) + + result = result.send(:add_fraction, frac_part) if frac_part.nonzero? + + result + end + + def add_days(days) + new_jd = @jd + days + new_nth = @nth + + while new_jd < 0 + new_nth -= 1 + new_jd += CM_PERIOD + end + + while new_jd >= CM_PERIOD + new_nth += 1 + new_jd -= CM_PERIOD + end + + obj = self.class.allocate + obj.instance_variable_set(:@nth, new_nth) + obj.instance_variable_set(:@jd, new_jd) + obj.instance_variable_set(:@sg, @sg) + obj.instance_variable_set(:@flags, HAVE_JD) + obj.instance_variable_set(:@year, nil) + obj.instance_variable_set(:@month, nil) + obj.instance_variable_set(:@day, nil) + + obj + end + + def add_fraction(frac) + # In the C implementation, Date.jd(2451944.5) becomes 2451945, + # so if there is a decimal point, it will be rounded up by one day. + if frac > 0 + add_days(1) + else + self + end + end + + def last_day_of_month_gregorian(y, m) + self.class.send(:last_day_of_month_gregorian, y, m) + end + + def last_day_of_month_julian(y, m) + self.class.send(:last_day_of_month_julian, y, m) + end + + def valid_civil_date?(year, month, day, sg) + self.class.send(:valid_civil_date?, year, month, day, sg) + end + + def canonicalize_jd(nth, jd) + if jd < 0 + nth = nth - 1 + jd += CM_PERIOD + end + if jd >= CM_PERIOD + nth = nth + 1 + jd -= CM_PERIOD + end + + [nth, jd] + end + + # If any of @df, @sf, or @of is not nil, it is considered complex. + def simple_dat_p? + @df.nil? && @sf.nil? && @of.nil? + end + + def complex_dat_p? + !simple_dat_p? + end + + def m_gregorian_p? + !m_julian_p? + end + + def m_julian_p? + # Divide the processing into simple and complex. + if simple_dat_p? + get_s_jd + jd = @jd + sg = s_virtual_sg + else + get_c_jd + jd = @jd + sg = c_virtual_sg + end + + return sg == JULIAN if sg.infinite? + + jd < sg + end + + def m_year + simple_dat_p? ? get_s_civil : get_c_civil + + @year + end + + def m_virtual_sg + simple_dat_p? ? s_virtual_sg : c_virtual_sg + end + + def get_s_jd + # For simple data, if JD has not yet been calculated. + return if @has_jd + + return unless simple_dat_p? + + return unless @has_civil + + # Calculate JD from civil. + jd, _ = self.class.send(:c_civil_to_jd, @year, @month, @day, s_virtual_sg) + @jd = jd + @has_jd = true + end + + def get_s_civil + # For simple data, if civil has not yet been calculated. + return if @has_civil + + # If not simple or there is no JD, do nothing. + return unless simple_dat_p? + return unless @has_jd + + # Calculate civil from JD. + y, m, d = self.class.send(:c_jd_to_civil, @jd, s_virtual_sg) + @year = y + @month = m + @day = d + @has_civil = true + end + + def get_c_jd + # For complex data, if JD has not yet been calculated. + return if @has_jd + + # Make sure you have civil data. + raise "No civil data" unless @has_civil + + # Calculate JD from civil. + jd, _ = self.class.send(:c_civil_to_jd, @year, @month, @day, c_virtual_sg) + + # Consider time data. + get_c_time + + # Convert from local to UTC. + @jd = jd_local_to_utc(jd, time_to_df(@hour || 0, @min || 0, @sec || 0), @of || 0) + @has_jd = true + end + + def get_c_civil + # For complex data, if civil has not yet been calculated. + return if @has_civil + + # Make sure you have a JD. + raise "No JD data" unless @has_jd + + get_c_df + + # Convert UTC to local. + jd = jd_utc_to_local(@jd, @df || 0, @of || 0) + + # Calculate civil from JD. + y, m, d = self.class.send(:c_jd_to_civil, jd, c_virtual_sg) + @year = y + @month = m + @day = d + @has_civil = true + end + + def get_c_df + # If df (day fraction) has not yet been calculated. + return if @df + + # Check that time data is available. + raise "No time data" if @hour.nil? && @min.nil? && @sec.nil? + + # Convert time to df + @df = df_local_to_utc(time_to_df(@hour, @min, @sec), @of || 0) + end + + def get_c_time + # If the time data has not yet been calculated. + return unless @hour.nil? + + # Make sure df exists. + raise "No df data" if @df.nil? + + # Convert df to time. + r = df_utc_to_local(@df, @of || 0) + + @hour, @min, @sec = df_to_time(r) + end + + # For SimpleDateData (effectively a common implementation) + def s_virtual_sg + return @sg if @sg.infinite? + return @sg if self.class.send(:f_zero_p?, @nth) + + @nth < 0 ? JULIAN : GREGORIAN + end + + # For ComplexDateData (effectively a common implementation) + def c_virtual_sg + return @sg if @sg.infinite? + return @sg if self.class.send(:f_zero_p?, @nth) + + @nth < 0 ? JULIAN : GREGORIAN + end + + def jd_local_to_utc(jd, df, of) + df -= of + if df < 0 + jd -= 1 + elsif df >= DAY_IN_SECONDS + jd += 1 + end + + jd + end + + def jd_utc_to_local(jd, df, of) + df += of + if df < 0 + jd -= 1 + elsif df >= DAY_IN_SECONDS + jd += 1 + end + + jd + end + + def df_local_to_utc(df, of) + df -= of + if df < 0 + df += DAY_IN_SECONDS + elsif df >= DAY_IN_SECONDS + df -= DAY_IN_SECONDS + end + + df + end + + def df_utc_to_local(df, of) + df += of + if df < 0 + df += DAY_IN_SECONDS + elsif df >= DAY_IN_SECONDS + df -= DAY_IN_SECONDS + end + + df + end + + def time_to_df(h, min, s) + h * HOUR_IN_SECONDS + min * MINUTE_IN_SECONDS + s + end + + def df_to_time(df) + h = df / HOUR_IN_SECONDS + df %= HOUR_IN_SECONDS + min = df / MINUTE_IN_SECONDS + s = df % MINUTE_IN_SECONDS + + [h, min, s] + end + + def minus_dd(other) + n = m_nth - other.send(:m_nth) + d = m_jd - other.send(:m_jd) + df = m_df - other.send(:m_df) + sf = m_sf - other.send(:m_sf) + + n, d = canonicalize_jd(n, d) + + # Canonicalize df + if df < 0 + d -= 1 + df += DAY_IN_SECONDS + elsif df >= DAY_IN_SECONDS + d += 1 + df -= DAY_IN_SECONDS + end + + # Canonicalize sf + if sf < 0 + df -= 1 + sf += SECOND_IN_NANOSECONDS + elsif sf >= SECOND_IN_NANOSECONDS + df += 1 + sf -= SECOND_IN_NANOSECONDS + end + + r = n.zero? ? 0 : n * CM_PERIOD + r = r + Rational(d, 1) if d.nonzero? + r = r + isec_to_day(df) if df.nonzero? + r = r + ns_to_day(sf) if sf.nonzero? + + r.is_a?(Rational) ? r : Rational(r, 1) + end + + def m_jd + simple_dat_p? ? get_s_jd : get_c_jd + + @jd + end + + def m_df + if simple_dat_p? + 0 + else + get_c_df + @df || 0 + end + end + + def m_sf + simple_dat_p? ? 0 : @sf || 0 + end + + def m_of + if simple_dat_p? + 0 + else + get_c_jd + @of || 0 + end + end + + def m_real_year + nth = @nth + year = m_year + + return year if self.class.send(:f_zero_p?, nth) + + encode_year(nth, year, gregorian? ? -1 : 1) + end + + def m_mon + simple_dat_p? ? get_s_civil : get_c_civil + + @month + end + + def m_mday + simple_dat_p? ? get_s_civil : get_c_civil + + @day + end + + def m_sg + get_c_jd unless simple_dat_p? + + @sg + end + + def encode_year(nth, y, style) + period = (style < 0) ? CM_PERIOD_GCY : CM_PERIOD_JCY + + self.class.send(:f_zero_p?, nth) ? y : period * nth + y + end + + def m_real_local_jd + nth = m_nth + jd = m_local_jd + + self.class.send(:encode_jd, nth, jd) + end + + def m_local_jd + if simple_dat_p? + get_s_jd + @jd + else + get_c_jd + get_c_df + local_jd + end + end + + def local_jd + jd = @jd + df = @df || 0 + of = @of || 0 + + df += of + if df < 0 + jd -= 1 + elsif df >= DAY_IN_SECONDS + jd += 1 + end + + jd + end + + def m_nth + # For complex, get civil data and then return nth. + get_c_civil unless simple_dat_p? + + @nth + end + + def equal_gen(other) + if other.is_a?(Numeric) + m_real_local_jd == other + elsif other.is_a?(Date) + m_real_local_jd == other.send(:m_real_local_jd) + else + begin + coerced = other.coerce(self) + coerced[0] == coerced[1] + rescue + false + end + end + end + + def m_canonicalize_jd + if simple_dat_p? + get_s_jd + canonicalize_s_jd + else + get_c_jd + canonicalize_c_jd + end + end + + # Simple + def canonicalize_s_jd + return if frozen? + + j = @jd + + @nth, @jd = canonicalize_jd(@nth, @jd) + + # Invalidate civil data if JD changes. + @has_civil = false if @jd != j + end + + # Complex + def canonicalize_c_jd + return if frozen? + + j = @jd + + @nth, @jd = canonicalize_jd(@nth, @jd) + + # Invalidate civil data if JD changes. + @has_civil = false if @jd != j + end + + def f_jd(other) + # Get JD from another Date object. + other.send(:m_real_local_jd) + end + + def dup_obj_with_new_start(sg) + dup = dup_obj + dup.send(:set_sg, sg) + + dup + end + + def dup_obj + if simple_dat_p? + # Simple data replication + new_obj = self.class.send(:d_lite_s_alloc_simple) + + new_obj.instance_variable_set(:@nth, canon(@nth)) + new_obj.instance_variable_set(:@jd, @jd) + new_obj.instance_variable_set(:@sg, @sg) + new_obj.instance_variable_set(:@year, @year) + new_obj.instance_variable_set(:@month, @month) + new_obj.instance_variable_set(:@day, @day) + new_obj.instance_variable_set(:@has_jd, @has_jd) + new_obj.instance_variable_set(:@has_civil, @has_civil) + new_obj.instance_variable_set(:@df, nil) + new_obj.instance_variable_set(:@sf, nil) + new_obj.instance_variable_set(:@of, nil) + + new_obj + else + # Complex data replication + new_obj = self.class.send(:d_lite_s_alloc_complex) + + new_obj.instance_variable_set(:@nth, canon(@nth)) + new_obj.instance_variable_set(:@jd, @jd) + new_obj.instance_variable_set(:@sg, @sg) + new_obj.instance_variable_set(:@year, @year) + new_obj.instance_variable_set(:@month, @month) + new_obj.instance_variable_set(:@day, @day) + new_obj.instance_variable_set(:@hour, @hour) + new_obj.instance_variable_set(:@min, @min) + new_obj.instance_variable_set(:@sec, @sec) + new_obj.instance_variable_set(:@df, @df) + new_obj.instance_variable_set(:@sf, canon(@sf)) + new_obj.instance_variable_set(:@of, @of) + new_obj.instance_variable_set(:@has_jd, @has_jd) + new_obj.instance_variable_set(:@has_civil, @has_civil) + + new_obj + end + end + + def set_sg(sg) + if simple_dat_p? + get_s_jd if @has_jd || @has_civil + else + get_c_jd + get_c_df + end + + clear_civil + + @sg = sg + end + + def clear_civil + @has_civil = false + @year = nil + @month = nil + @day = nil + end + + # If the argument is Rational and the denominator is 1, return the numerator. + def canon(x) + x.is_a?(Rational) && x.denominator == 1 ? x.numerator : x + end + + def val2sg(vsg) + # Convert to Number. + sg = vsg.to_f + + # Check for a valid start. + unless c_valid_start_p?(sg) + warn "invalid start is ignored" + sg = DEFAULT_SG + end + + sg + end + + def c_valid_start_p?(sg) + # Invalid for NaN. + return false if sg.respond_to?(:nan?) && sg.nan? + + # Valid for Infinity. + return true if sg.respond_to?(:infinite?) && sg.infinite? + + # If it is a finite value, check if it is within the range + # from REFORM_BEGIN_JD to REFORM_END_JD + return false if sg < REFORM_BEGIN_JD || sg > REFORM_END_JD + + true + end + + def m_wday + c_jd_to_wday(m_local_jd) + end + + def c_jd_to_wday(jd) + (jd + 1) % 7 + end + + def m_yday + jd = m_local_jd + sg = m_virtual_sg + + # proleptic gregorian or if more than 366 days have passed since the calendar change + return c_gregorian_to_yday(m_year, m_mon, m_mday) if m_proleptic_gregorian_p? || (jd - sg) > 366 + + # proleptic Julian + return c_julian_to_yday(m_year, m_mon, m_mday) if m_proleptic_julian_p? + + # Otherwise, convert from JD to ordinal. + _, rd = self.class.send(:c_jd_to_ordinal, jd, sg) + + rd + end + + def m_proleptic_gregorian_p? + sg = @sg + + sg.infinite? && sg < 0 + end + + def m_proleptic_julian_p? + sg = @sg + + sg.infinite? && sg > 0 + end + + def c_gregorian_to_yday(year, month, day) + leap = self.class.send(:c_gregorian_leap_p?, year) + + YEARTAB[leap ? 1 : 0][month] + day + end + + def c_julian_to_yday(year, month, day) + leap = self.class.send(:c_julian_leap_p?, year) + + YEARTAB[leap ? 1 : 0][month] + day + end + + def d_trunc_with_frac(value) + if value.is_a?(Integer) + [value, 0] + elsif value.is_a?(Float) + trunc = value.truncate + frac = value - trunc + + [trunc, frac] + elsif value.is_a?(Rational) + trunc = value.truncate + frac = value - trunc + + [trunc, frac] + else + [value.to_i, 0] + end + end + + def m_real_jd + # C: m_real_jd uses m_jd (raw/UTC JD), NOT m_local_jd + nth = m_nth + if simple_dat_p? + get_s_jd + else + get_c_jd + end + self.class.send(:encode_jd, nth, @jd) + end + + def m_fr + if simple_dat_p? + 0 + else + df = m_local_df + sf = m_sf + + fr = isec_to_day(df) + fr = fr + ns_to_day(sf) if sf.nonzero? + + fr + end + end + + def m_local_df + if simple_dat_p? + 0 + else + get_c_df + local_df + end + end + + def local_df + df_utc_to_local(@df || 0, @of || 0) + end + + def isec_to_day(s) + sec_to_day(s) + end + + def sec_to_day(s) + s.is_a?(Integer) ? Rational(s, DAY_IN_SECONDS) : s.quo(DAY_IN_SECONDS) + end + + def ns_to_day(n) + if n.is_a?(Integer) + Rational(n, DAY_IN_SECONDS * SECOND_IN_NANOSECONDS) + else + n.quo(DAY_IN_SECONDS * SECOND_IN_NANOSECONDS) + end + end + + def m_real_cwyear + nth = m_nth + year = m_cwyear + + nth.zero? ? year : encode_year(nth, year, m_gregorian_p? ? -1 : 1) + end + + def m_cwyear + jd = m_local_jd + sg = m_virtual_sg + + ry, _, _ = self.class.send(:c_jd_to_commercial, jd, sg) + + ry + end + + def m_cweek + jd = m_local_jd + sg = m_virtual_sg + + _, rw, _ = self.class.send(:c_jd_to_commercial, jd, sg) + + rw + end + + def m_cwday + w = m_wday + # ISO 8601 places Sunday at 7. + w.zero? ? 7 : w + end + + def m_ajd + if simple_dat_p? + # For simple date: + r = m_real_jd + + # Optimization: Integer operations within Fixnum range + if r.is_a?(Integer) && r <= (2**62 - 1) / 2 && r >= (-(2**62) + 1) / 2 + ir = r * 2 - 1 + return Rational(ir, 2) + else + return Rational(r * 2 - 1, 2) + end + end + + # For complex date: + r = m_real_jd + df = m_df + + # Subtract half a day (12 hours) from df. + df -= HALF_DAYS_IN_SECONDS + + # If df is not zero, add. + r = r + isec_to_day(df) if df != 0 + + # If sf is not zero, add. + sf = m_sf + r = r + ns_to_day(sf) if sf != 0 + + r + end + + def m_amjd + r = m_real_jd + + # Optimization: Integer operations within Fixnum range + if r.is_a?(Integer) && r >= (-(2**62) + 2400001) + ir = r - 2400001 + r = Rational(ir, 1) + else + r = Rational(m_real_jd - 2400001, 1) + end + + # For simple date, stop here. + return r if simple_dat_p? + + # For complex date, add df and sf. + df = m_df + r = r + isec_to_day(df) if df != 0 + + sf = m_sf + r = r + ns_to_day(sf) if sf != 0 + + r + end + + def m_wnumx(f) + _, rw, _ = c_jd_to_weeknum(m_local_jd, f, m_virtual_sg) + + rw + end + + def c_jd_to_weeknum(jd, f, sg) + ry, _, _ = self.class.send(:c_jd_to_civil, jd, sg) + rjd, _ = self.class.send(:c_find_fdoy, ry, sg) + + rjd += 6 + + mod_val = euclidean_mod((rjd - f) + 1, 7) + j = jd - (rjd - mod_val) + 7 + + rw = euclidean_div(j, 7) + rd = euclidean_mod(j, 7) + + [ry, rw, rd] + end + + # Euclidean division (equivalent to the DIV macro in C) + def euclidean_div(a, b) + q = a / b + r = a % b + # In Ruby, a remainder of a negative number is negative, so adjust it accordingly. + if r < 0 + if b > 0 + q -= 1 + else + q += 1 + end + end + + q + end + + # Euclidean modulo (equivalent to the MOD macro in C) + def euclidean_mod(a, b) + r = a % b + # In Ruby, a remainder of a negative number is negative, so adjust it accordingly. + if r < 0 + if b > 0 + r += b + else + r -= b + end + end + + r + end + + def jisx0301_date_format(jd, year) + # If jd is not a Fixnum (Integer in Ruby), use ISO format + return '%Y-%m-%d' unless jd.is_a?(Integer) + + # Determine the era based on Julian Day Number + if jd < 2405160 + # Before Meiji era (before 1868-01-25) + '%Y-%m-%d' + elsif jd < 2419614 + # Meiji era (M) 1868-01-25 to 1912-07-29 + era_char = 'M' + era_start = 1867 + format_era(era_char, year, era_start) + elsif jd < 2424875 + # Taisho era (T) 1912-07-30 to 1926-12-24 + era_char = 'T' + era_start = 1911 + format_era(era_char, year, era_start) + elsif jd < 2447535 + # Showa era (S) 1926-12-25 to 1989-01-07 + era_char = 'S' + era_start = 1925 + format_era(era_char, year, era_start) + elsif jd < 2458605 + # Heisei era (H) 1989-01-08 to 2019-04-30 + era_char = 'H' + era_start = 1988 + format_era(era_char, year, era_start) + else + # Reiwa era (R) 2019-05-01 onwards + era_char = 'R' + era_start = 2018 + format_era(era_char, year, era_start) + end + end + + def format_era(era_char, year, era_start) + era_year = year - era_start + "#{era_char}%02d.%%m.%%d" % era_year + end + + def zone + of = @parsed_offset || 0 + s = of < 0 ? '-' : '+' + a = of.abs + h = a / HOUR_IN_SECONDS + m = a % HOUR_IN_SECONDS / MINUTE_IN_SECONDS + "%c%02d:%02d" % [s, h, m] + end + + # C: old_to_new — converts old ajd/of/sg format to new nth/jd/df/sf/of/sg + def old_to_new(ajd, of, sg) + # C: decode_day(ajd + half_days_in_day, &jd, &df, &sf) + d = ajd + Rational(1, 2) + + # div_day: jd = floor(d), f = d mod 1 + jd_full = d.floor + f = d - jd_full + + # div_df: df = floor(f * DAY_IN_SECONDS), remainder + df_rational = f * 86400 + df = df_rational.floor + sf_frac = df_rational - df + + # sec_to_ns: sf = round(sf_frac * SECOND_IN_NANOSECONDS) + sf = (sf_frac * 1_000_000_000).round + + # C: day_to_sec(of) then round + of_sec = (of * 86400).round.to_i + + # C: decode_jd(jd, &nth, &rjd) + nth = jd_full.div(CM_PERIOD) + rjd = (jd_full % CM_PERIOD).to_i + + # Validations (C: old_to_new) + raise Error, "invalid day fraction" if df < 0 || df >= 86400 + if of_sec < -86400 || of_sec > 86400 + of_sec = 0 + warn "invalid offset is ignored" + end + + # Convert Infinity to Float (C: NUM2DBL(sg)) + if sg.is_a?(Infinity) + sg = case sg.send(:d) + when -1 then -Float::INFINITY + when 1 then Float::INFINITY + else DEFAULT_SG + end + end + + [nth, rjd, df, sf, of_sec, sg] + end +end diff --git a/lib/date/datetime.rb b/lib/date/datetime.rb new file mode 100644 index 0000000..844447d --- /dev/null +++ b/lib/date/datetime.rb @@ -0,0 +1,826 @@ +# frozen_string_literal: true + +# Implementation of DateTime from ruby/date/ext/date/date_core.c +# DateTime is a subclass of Date that includes time-of-day and timezone. +class DateTime < Date + # call-seq: + # DateTime.new(year=-4712, month=1, day=1, hour=0, minute=0, second=0, offset=0, start=Date::ITALY) -> datetime + # + # Creates a new DateTime object. + def initialize(year = -4712, month = 1, day = 1, hour = 0, minute = 0, second = 0, offset = 0, start = ITALY) + y = year + m = month + d = day + h = hour + min = minute + s = second + fr2 = 0 + + # argument type checking + raise TypeError, "invalid year (not numeric)" unless y.is_a?(Numeric) + raise TypeError, "invalid month (not numeric)" unless m.is_a?(Numeric) + raise TypeError, "invalid day (not numeric)" unless d.is_a?(Numeric) + raise TypeError, "invalid hour (not numeric)" unless h.is_a?(Numeric) + raise TypeError, "invalid minute (not numeric)" unless min.is_a?(Numeric) + raise TypeError, "invalid second (not numeric)" unless s.is_a?(Numeric) + + # Handle fractional day (C: d_trunc) + d_trunc, fr = d_trunc_with_frac(d) + d = d_trunc + fr2 = fr if fr.nonzero? + + # Handle fractional hour (C: h_trunc via num2int_with_frac) + h_int = h.to_i + h_frac = h - h_int + if h_frac.nonzero? + fr2 = fr2 + Rational(h_frac) / 24 + h = h_int + end + + # Handle fractional minute (C: min_trunc) + min_int = min.to_i + min_frac = min - min_int + if min_frac.nonzero? + fr2 = fr2 + Rational(min_frac) / 1440 + min = min_int + end + + # Handle fractional second (C: s_trunc) + # C converts sub-second fraction to day fraction: fr2 = frac / DAY_IN_SECONDS + s_int = s.to_i + s_frac = s - s_int + if s_frac.nonzero? + fr2 = fr2 + Rational(s_frac) / DAY_IN_SECONDS + s = s_int + end + + # Convert offset to integer seconds (C: val2off → offset_to_sec) + rof = offset_to_sec(offset) + + sg = self.class.send(:valid_sg, start) + style = self.class.send(:guess_style, y, sg) + + # Validate time (C: c_valid_time_p) + h, min, s = validate_time(h, min, s) + + # Handle hour 24 (C: canon24oc) + if h == 24 + h = 0 + fr2 = fr2 + 1 + end + + if style < 0 + # gregorian calendar only + result = self.class.send(:valid_gregorian_p, y, m, d) + raise Error, "invalid date" unless result + + nth, ry = self.class.send(:decode_year, y, -1) + rm = result[:rm] + rd = result[:rd] + + rjd, _ = self.class.send(:c_civil_to_jd, ry, rm, rd, GREGORIAN) + rjd2 = jd_local_to_utc(rjd, time_to_df(h, min, s), rof) + + @nth = canon(nth) + @jd = rjd2 + @sg = sg + @year = ry + @month = rm + @day = rd + @has_jd = true + @has_civil = true + @hour = h + @min = min + @sec = s + @df = df_local_to_utc(time_to_df(h, min, s), rof) + @sf = 0 + @of = rof + else + # full validation + result = self.class.send(:valid_civil_p, y, m, d, sg) + raise Error, "invalid date" unless result + + nth = result[:nth] + ry = result[:ry] + rm = result[:rm] + rd = result[:rd] + rjd = result[:rjd] + + rjd2 = jd_local_to_utc(rjd, time_to_df(h, min, s), rof) + + @nth = canon(nth) + @jd = rjd2 + @sg = sg + @year = ry + @month = rm + @day = rd + @has_jd = true + @has_civil = true + @hour = h + @min = min + @sec = s + @df = df_local_to_utc(time_to_df(h, min, s), rof) + @sf = 0 + @of = rof + end + + # Add accumulated fractional parts (C: add_frac) + if fr2.nonzero? + new_date = self + fr2 + @nth = new_date.instance_variable_get(:@nth) + @jd = new_date.instance_variable_get(:@jd) + @sg = new_date.instance_variable_get(:@sg) + @year = new_date.instance_variable_get(:@year) + @month = new_date.instance_variable_get(:@month) + @day = new_date.instance_variable_get(:@day) + @has_jd = new_date.instance_variable_get(:@has_jd) + @has_civil = new_date.instance_variable_get(:@has_civil) + @hour = new_date.instance_variable_get(:@hour) + @min = new_date.instance_variable_get(:@min) + @sec = new_date.instance_variable_get(:@sec) + @df = new_date.instance_variable_get(:@df) || @df + @sf = new_date.instance_variable_get(:@sf) || @sf + @of = new_date.instance_variable_get(:@of) || @of + end + + self + end + + # --- DateTime accessors (C: d_lite_hour etc.) --- + + # call-seq: + # hour -> integer + # + # Returns the hour in range (0..23). + def hour + if simple_dat_p? + 0 + else + get_c_time + @hour || 0 + end + end + + # call-seq: + # min -> integer + # + # Returns the minute in range (0..59). + def min + if simple_dat_p? + 0 + else + get_c_time + @min || 0 + end + end + alias minute min + + # call-seq: + # sec -> integer + # + # Returns the second in range (0..59). + def sec + if simple_dat_p? + 0 + else + get_c_time + @sec || 0 + end + end + alias second sec + + # call-seq: + # sec_fraction -> rational + # + # Returns the fractional part of the second: + # + # DateTime.new(2001, 2, 3, 4, 5, 6.5).sec_fraction # => (1/2) + # + # C: m_sf_in_sec = ns_to_sec(m_sf) + def sec_fraction + ns = m_sf + ns.zero? ? Rational(0) : Rational(ns, SECOND_IN_NANOSECONDS) + end + alias second_fraction sec_fraction + + # call-seq: + # offset -> rational + # + # Returns the offset as a fraction of day: + # + # DateTime.parse('04pm+0730').offset # => (5/16) + # + # C: m_of_in_day = isec_to_day(m_of) + def offset + of = m_of + of.zero? ? Rational(0) : Rational(of, DAY_IN_SECONDS) + end + + # call-seq: + # zone -> string + # + # Returns the timezone as a string: + # + # DateTime.parse('04pm+0730').zone # => "+07:30" + # + # C: m_zone → of2str(m_of) + def zone + if simple_dat_p? + "+00:00".encode(Encoding::US_ASCII) + else + of = m_of + s = of < 0 ? '-' : '+' + a = of < 0 ? -of : of + h = a / HOUR_IN_SECONDS + m = a % HOUR_IN_SECONDS / MINUTE_IN_SECONDS + ("%c%02d:%02d" % [s, h, m]).encode(Encoding::US_ASCII) + end + end + + STRFTIME_DATETIME_DEFAULT_FMT = '%FT%T%:z'.encode(Encoding::US_ASCII) + private_constant :STRFTIME_DATETIME_DEFAULT_FMT + + # Override Date#strftime with DateTime default format + def strftime(format = STRFTIME_DATETIME_DEFAULT_FMT) + super(format) + end + + # Override Date#jisx0301 for DateTime (includes time) + def jisx0301(n = 0) + n = n.to_i + if n == 0 + jd_val = send(:m_real_local_jd) + y = send(:m_real_year) + fmt = jisx0301_date_format(jd_val, y) + 'T%T%:z' + strftime(fmt) + else + s = jisx0301(0) + # insert fractional seconds before timezone + tz = s[-6..] # "+00:00" + base = s[0...-6] + frac = sec_fraction + if frac != 0 + f = format("%.#{n}f", frac.to_f)[1..] + base += f + else + base += '.' + '0' * n + end + base + tz + end + end + + # DateTime instance method - overrides Date#iso8601 + def iso8601(n = 0) + n = n.to_i + if n == 0 + strftime('%FT%T%:z') + else + s = strftime('%FT%T') + frac = sec_fraction + if frac != 0 + f = format("%.#{n}f", frac.to_f)[1..] + s += f + else + s += '.' + '0' * n + end + s + strftime('%:z') + end + end + alias_method :xmlschema, :iso8601 + alias_method :rfc3339, :iso8601 + + # call-seq: + # deconstruct_keys(array_of_names_or_nil) -> hash + # + # Returns name/value pairs for pattern matching. + # Includes Date keys (:year, :month, :day, :wday, :yday) + # plus DateTime keys (:hour, :min, :sec, :sec_fraction, :zone). + # + # C: dt_lite_deconstruct_keys (is_datetime=true) + def deconstruct_keys(keys) + if keys.nil? + return { + year: year, + month: month, + day: day, + yday: yday, + wday: wday, + hour: hour, + min: min, + sec: sec, + sec_fraction: sec_fraction, + zone: zone + } + end + + raise TypeError, "wrong argument type #{keys.class} (expected Array or nil)" unless keys.is_a?(Array) + + h = {} + keys.each do |key| + case key + when :year then h[:year] = year + when :month then h[:month] = month + when :day then h[:day] = day + when :yday then h[:yday] = yday + when :wday then h[:wday] = wday + when :hour then h[:hour] = hour + when :min then h[:min] = min + when :sec then h[:sec] = sec + when :sec_fraction then h[:sec_fraction] = sec_fraction + when :zone then h[:zone] = zone + end + end + h + end + + # call-seq: + # to_s -> string + # + # Returns a string in ISO 8601 DateTime format: + # + # DateTime.new(2001, 2, 3, 4, 5, 6, '+7').to_s + # # => "2001-02-03T04:05:06+07:00" + def to_s + sprintf("%04d-%02d-%02dT%02d:%02d:%02d%s".encode(Encoding::US_ASCII), year, month, day, hour, min, sec, zone) + end + + # call-seq: + # new_offset(offset = 0) -> datetime + # + # Returns a new DateTime object with the same date and time, + # but with the given +offset+. + # + # C: d_lite_new_offset + def new_offset(of = 0) + if of.is_a?(String) + of = Rational(offset_to_sec(of), DAY_IN_SECONDS) + elsif of.is_a?(Integer) && of == 0 + of = Rational(0) + end + raise TypeError, "invalid offset" unless of.is_a?(Rational) || of.is_a?(Integer) || of.is_a?(Float) + of = Rational(of) unless of.is_a?(Rational) + self.class.new(year, month, day, hour, min, sec + sec_fraction, of, start) + end + + # call-seq: + # to_date -> date + # + # Returns a Date for this DateTime (time information is discarded). + # C: dt_lite_to_date → copy civil, reset time + def to_date + nth, ry = self.class.send(:decode_year, year, -1) + Date.send(:d_simple_new_internal, + nth, 0, + @sg, + ry, month, day, + 0x04) # HAVE_CIVIL + end + + # call-seq: + # to_datetime -> self + # + # Returns self. + def to_datetime + self + end + + # call-seq: + # to_time -> time + # + # Returns a Time for this DateTime. + # C: dt_lite_to_time + def to_time + # C: dt_lite_to_time — converts Julian dates to Gregorian for Time compatibility + d = julian? ? gregorian : self + Time.new(d.year, d.month, d.day, d.hour, d.min, d.sec + d.sec_fraction, d.send(:m_of)) + end + + class << self + # Same as DateTime.new + alias_method :civil, :new + + undef_method :today + + # call-seq: + # DateTime.jd(jd=0, hour=0, minute=0, second=0, offset=0, start=Date::ITALY) -> datetime + # + # Creates a new DateTime from a Julian Day Number. + # C: dt_lite_s_jd + def jd(jd = 0, hour = 0, minute = 0, second = 0, offset = 0, start = Date::ITALY) + # Validate jd + raise TypeError, "invalid jd (not numeric)" unless jd.is_a?(Numeric) + raise TypeError, "invalid hour (not numeric)" unless hour.is_a?(Numeric) + raise TypeError, "invalid minute (not numeric)" unless minute.is_a?(Numeric) + raise TypeError, "invalid second (not numeric)" unless second.is_a?(Numeric) + + j, fr = value_trunc(jd) + nth, rjd = decode_jd(j) + + sg = valid_sg(start) + + # Validate time + h = hour.to_i + h_frac = hour - h + min_i = minute.to_i + min_frac = minute - min_i + s_i = second.to_i + s_frac = second - s_i + + fr2 = fr + fr2 = fr2 + Rational(h_frac) / 24 if h_frac.nonzero? + fr2 = fr2 + Rational(min_frac) / 1440 if min_frac.nonzero? + fr2 = fr2 + Rational(s_frac) / 86400 if s_frac.nonzero? + + rof = _offset_to_sec(offset) + + h += 24 if h < 0 + min_i += 60 if min_i < 0 + s_i += 60 if s_i < 0 + unless (0..24).cover?(h) && (0..59).cover?(min_i) && (0..59).cover?(s_i) && + !(h == 24 && (min_i > 0 || s_i > 0)) + raise Date::Error, "invalid date" + end + if h == 24 + h = 0 + fr2 = fr2 + 1 + end + + df = h * 3600 + min_i * 60 + s_i + df_utc = df - rof + jd_utc = rjd + if df_utc < 0 + jd_utc -= 1 + df_utc += 86400 + elsif df_utc >= 86400 + jd_utc += 1 + df_utc -= 86400 + end + + obj = new_with_jd_and_time(nth, jd_utc, df_utc, 0, rof, sg) + + obj = obj + fr2 if fr2.nonzero? + + obj + end + + # call-seq: + # DateTime.ordinal(year=-4712, yday=1, hour=0, minute=0, second=0, offset=0, start=Date::ITALY) -> datetime + # + # Creates a new DateTime from an ordinal date. + # C: dt_lite_s_ordinal + def ordinal(year = -4712, yday = 1, hour = 0, minute = 0, second = 0, offset = 0, start = Date::ITALY) + raise TypeError, "invalid year (not numeric)" unless year.is_a?(Numeric) + raise TypeError, "invalid yday (not numeric)" unless yday.is_a?(Numeric) + raise TypeError, "invalid hour (not numeric)" unless hour.is_a?(Numeric) + raise TypeError, "invalid minute (not numeric)" unless minute.is_a?(Numeric) + raise TypeError, "invalid second (not numeric)" unless second.is_a?(Numeric) + + # Truncate fractional yday + yday_int = yday.to_i + yday_frac = yday.is_a?(Integer) ? 0 : yday - yday_int + + result = valid_ordinal_p(year, yday_int, start) + raise Date::Error, "invalid date" unless result + + nth = result[:nth] + rjd = result[:rjd] + sg = valid_sg(start) + + rof = _offset_to_sec(offset) + + h = hour.to_i + h_frac = hour - h + min_i = minute.to_i + min_frac = minute - min_i + s_i = second.to_i + s_frac = second - s_i + + fr2 = yday_frac.nonzero? ? Rational(yday_frac) : 0 + fr2 = fr2 + Rational(h_frac) / 24 if h_frac.nonzero? + fr2 = fr2 + Rational(min_frac) / 1440 if min_frac.nonzero? + fr2 = fr2 + Rational(s_frac) / 86400 if s_frac.nonzero? + + h += 24 if h < 0 + min_i += 60 if min_i < 0 + s_i += 60 if s_i < 0 + unless (0..24).cover?(h) && (0..59).cover?(min_i) && (0..59).cover?(s_i) && + !(h == 24 && (min_i > 0 || s_i > 0)) + raise Date::Error, "invalid date" + end + if h == 24 + h = 0 + fr2 = fr2 + 1 + end + + df = h * 3600 + min_i * 60 + s_i + df_utc = df - rof + jd_utc = rjd + if df_utc < 0 + jd_utc -= 1 + df_utc += 86400 + elsif df_utc >= 86400 + jd_utc += 1 + df_utc -= 86400 + end + + obj = new_with_jd_and_time(nth, jd_utc, df_utc, 0, rof, sg) + + obj = obj + fr2 if fr2.nonzero? + + obj + 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 new DateTime from a commercial date. + # C: dt_lite_s_commercial + def commercial(cwyear = -4712, cweek = 1, cwday = 1, hour = 0, minute = 0, second = 0, offset = 0, start = Date::ITALY) + raise TypeError, "invalid cwyear (not numeric)" unless cwyear.is_a?(Numeric) + raise TypeError, "invalid cweek (not numeric)" unless cweek.is_a?(Numeric) + raise TypeError, "invalid cwday (not numeric)" unless cwday.is_a?(Numeric) + raise TypeError, "invalid hour (not numeric)" unless hour.is_a?(Numeric) + raise TypeError, "invalid minute (not numeric)" unless minute.is_a?(Numeric) + raise TypeError, "invalid second (not numeric)" unless second.is_a?(Numeric) + + # Truncate fractional cwday + cwday_int = cwday.to_i + cwday_frac = cwday.is_a?(Integer) ? 0 : cwday - cwday_int + + result = valid_commercial_p(cwyear, cweek, cwday_int, start) + raise Date::Error, "invalid date" unless result + + nth = result[:nth] + rjd = result[:rjd] + sg = valid_sg(start) + + rof = _offset_to_sec(offset) + + h = hour.to_i + h_frac = hour - h + min_i = minute.to_i + min_frac = minute - min_i + s_i = second.to_i + s_frac = second - s_i + + fr2 = cwday_frac.nonzero? ? Rational(cwday_frac) : 0 + fr2 = fr2 + Rational(h_frac) / 24 if h_frac.nonzero? + fr2 = fr2 + Rational(min_frac) / 1440 if min_frac.nonzero? + fr2 = fr2 + Rational(s_frac) / 86400 if s_frac.nonzero? + + h += 24 if h < 0 + min_i += 60 if min_i < 0 + s_i += 60 if s_i < 0 + unless (0..24).cover?(h) && (0..59).cover?(min_i) && (0..59).cover?(s_i) && + !(h == 24 && (min_i > 0 || s_i > 0)) + raise Date::Error, "invalid date" + end + if h == 24 + h = 0 + fr2 = fr2 + 1 + end + + df = h * 3600 + min_i * 60 + s_i + df_utc = df - rof + jd_utc = rjd + if df_utc < 0 + jd_utc -= 1 + df_utc += 86400 + elsif df_utc >= 86400 + jd_utc += 1 + df_utc -= 86400 + end + + obj = new_with_jd_and_time(nth, jd_utc, df_utc, 0, rof, sg) + + obj = obj + fr2 if fr2.nonzero? + + obj + end + + # call-seq: + # DateTime.strptime(string='-4712-01-01T00:00:00+00:00', format='%FT%T%z', start=Date::ITALY) -> datetime + # + # Parses +string+ according to +format+ and creates a DateTime. + # C: dt_lite_s_strptime + def strptime(string = '-4712-01-01T00:00:00+00:00', format = '%FT%T%z', start = Date::ITALY) + hash = _strptime(string, format) + dt_new_by_frags(hash, start) + end + + # Override Date._strptime default format for DateTime + def _strptime(string, format = '%FT%T%z') + super(string, format) + end + + # call-seq: + # DateTime.now(start = Date::ITALY) -> datetime + # + # Creates a DateTime for the current time. + # + # C: datetime_s_now + def now(start = Date::ITALY) + t = Time.now + sg = valid_sg(start) + + of = t.utc_offset # integer seconds + + new( + t.year, t.mon, t.mday, + t.hour, t.min, t.sec + Rational(t.nsec, 1_000_000_000), + Rational(of, 86400), + sg + ) + end + + # call-seq: + # DateTime.parse(string, comp = true, start = Date::ITALY, limit: 128) -> datetime + # + # Parses +string+ and creates a DateTime. + # + # C: date_parse → dt_new_by_frags + def parse(string = JULIAN_EPOCH_DATETIME, comp = true, start = Date::ITALY, limit: 128) + hash = _parse(string, comp, limit: limit) + dt_new_by_frags(hash, start) + end + + # Format-specific constructors delegate to _xxx + dt_new_by_frags + + def iso8601(string = JULIAN_EPOCH_DATETIME, start = Date::ITALY, limit: 128) + hash = _iso8601(string, limit: limit) + dt_new_by_frags(hash, start) + end + + def rfc3339(string = JULIAN_EPOCH_DATETIME, start = Date::ITALY, limit: 128) + hash = _rfc3339(string, limit: limit) + dt_new_by_frags(hash, start) + end + + def xmlschema(string = JULIAN_EPOCH_DATETIME, start = Date::ITALY, limit: 128) + hash = _xmlschema(string, limit: limit) + dt_new_by_frags(hash, start) + end + + def rfc2822(string = JULIAN_EPOCH_DATETIME_RFC2822, start = Date::ITALY, limit: 128) + hash = _rfc2822(string, limit: limit) + dt_new_by_frags(hash, start) + end + alias_method :rfc822, :rfc2822 + + def httpdate(string = JULIAN_EPOCH_DATETIME_HTTPDATE, start = Date::ITALY, limit: 128) + hash = _httpdate(string, limit: limit) + dt_new_by_frags(hash, start) + end + + def jisx0301(string = JULIAN_EPOCH_DATETIME, start = Date::ITALY, limit: 128) + hash = _jisx0301(string, limit: limit) + dt_new_by_frags(hash, start) + end + + private + + JULIAN_EPOCH_DATETIME = '-4712-01-01T00:00:00+00:00' + JULIAN_EPOCH_DATETIME_RFC2822 = 'Mon, 1 Jan -4712 00:00:00 +0000' + JULIAN_EPOCH_DATETIME_HTTPDATE = 'Mon, 01 Jan -4712 00:00:00 GMT' + + # C: offset_to_sec / val2off (class method version for use in class << self) + def _offset_to_sec(of) + case of + when Integer + of + when Rational + (of * 86400).to_i + when Float + (of * 86400).to_i + when String + if of.strip.upcase == 'Z' + 0 + elsif of =~ /\A([+-])(\d{1,2}):(\d{2})\z/ + sign = $1 == '-' ? -1 : 1 + sign * ($2.to_i * 3600 + $3.to_i * 60) + elsif of =~ /\A([+-])(\d{2})(\d{2})?\z/ + sign = $1 == '-' ? -1 : 1 + sign * ($2.to_i * 3600 + ($3 ? $3.to_i * 60 : 0)) + else + 0 + end + else + 0 + end + end + + # C: dt_new_by_frags (date_core.c:8434) + # + # Structure matches C exactly: + # 1. Fast path: year+mon+mday present, no jd/yday + # - Validate civil, default time to 0, clamp sec==60 → 59 + # 2. Slow path: rt_rewrite_frags → rt_complete_frags → rt__valid_date_frags_p + # 3. Validate time (c_valid_time_p), handle sec_fraction, offset + # 4. Construct DateTime + def dt_new_by_frags(hash, sg) + raise Date::Error, "invalid date" if hash.nil? || hash.empty? + + # --- Fast path (C: lines 8447-8466) --- + if !hash.key?(:jd) && !hash.key?(:yday) && + hash[:year] && hash[:mon] && hash[:mday] + + y = hash[:year]; m = hash[:mon]; d = hash[:mday] + raise Date::Error, "invalid date" unless valid_civil?(y, m, d, sg) + + # C: default time fields, clamp sec==60 + hash[:hour] = 0 unless hash.key?(:hour) + hash[:min] = 0 unless hash.key?(:min) + if !hash.key?(:sec) + hash[:sec] = 0 + elsif hash[:sec] == 60 + hash[:sec] = 59 + end + + # --- Slow path (C: lines 8467-8470) --- + # rt_complete_frags needs DateTime as klass for time-only fill-in. + # rt__valid_date_frags_p needs Date for validation (calls ordinal/new). + else + hash = Date.send(:rt_rewrite_frags, hash) + hash = Date.send(:rt_complete_frags, self, hash) + jd_val = Date.send(:rt__valid_date_frags_p, hash, sg) + raise Date::Error, "invalid date" unless jd_val + + # Convert JD to civil for constructor + y, m, d = Date.send(:c_jd_to_civil, jd_val, sg) + end + + # --- Time validation (C: c_valid_time_p, lines 8473-8480) --- + h = hash[:hour] || 0 + min = hash[:min] || 0 + s = hash[:sec] || 0 + + # C: c_valid_time_p normalizes negative values and validates range. + rh = h < 0 ? h + 24 : h + rmin = min < 0 ? min + 60 : min + rs = s < 0 ? s + 60 : s + unless (0..24).cover?(rh) && (0..59).cover?(rmin) && (0..59).cover?(rs) && + !(rh == 24 && (rmin > 0 || rs > 0)) + raise Date::Error, "invalid date" + end + + # --- sec_fraction (C: lines 8482-8486) --- + sf = hash[:sec_fraction] + s_with_frac = sf ? rs + sf : rs + + # --- offset (C: lines 8488-8495) --- + of_sec = hash[:offset] || 0 + if of_sec.abs > 86400 + warn "invalid offset is ignored" + of_sec = 0 + end + of = Rational(of_sec, 86400) + + # --- Construct DateTime --- + new(y, m, d, rh, rmin, s_with_frac, of, sg) + end + end + + private + + # Convert offset argument to integer seconds. + # Accepts: Integer (seconds), Rational (fraction of day), String ("+HH:MM"), 0 + # C: offset_to_sec / val2off + def offset_to_sec(of) + case of + when Integer + of + when Float + # Fraction of day to seconds + (of * DAY_IN_SECONDS).to_i + when Rational + # Fraction of day to seconds + (of * DAY_IN_SECONDS).to_i + when String + if of.strip.upcase == 'Z' + 0 + elsif of =~ /\A([+-])(\d{2}):(\d{2})\z/ + sign = $1 == '-' ? -1 : 1 + sign * ($2.to_i * HOUR_IN_SECONDS + $3.to_i * MINUTE_IN_SECONDS) + elsif of =~ /\A([+-])(\d{2})(\d{2})?\z/ + sign = $1 == '-' ? -1 : 1 + sign * ($2.to_i * HOUR_IN_SECONDS + ($3 ? $3.to_i * MINUTE_IN_SECONDS : 0)) + else + 0 + end + else + 0 + end + end + + # Validate time fields (C: c_valid_time_p) + def validate_time(h, min, s) + h += 24 if h < 0 + min += 60 if min < 0 + s += 60 if s < 0 + unless (0..24).cover?(h) && (0..59).cover?(min) && (0..59).cover?(s) && + !(h == 24 && (min > 0 || s > 0)) + raise Error, "invalid date" + end + [h, min, s] + end +end diff --git a/lib/date/parse.rb b/lib/date/parse.rb new file mode 100644 index 0000000..095a522 --- /dev/null +++ b/lib/date/parse.rb @@ -0,0 +1,2607 @@ +# frozen_string_literal: true + +require_relative "patterns" +require_relative "zonetab" + +# Implementation of ruby/date/ext/date/date_parse.c +class Date + class << self + # call-seq: + # Date.parse(string = '-4712-01-01', comp = true, start = Date::ITALY, limit: 128) -> date + # + # Returns a new \Date object with values parsed from +string+. + # + # 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. + # + # See argument {start}[rdoc-ref:language/calendars.rdoc@Argument+start]. + # See argument {limit}[rdoc-ref:Date@Argument+limit]. + # + # Related: Date._parse (returns a hash). + def parse(string = JULIAN_EPOCH_DATE, comp = true, start = DEFAULT_SG, limit: 128) + hash = _parse(string, comp, limit: limit) + new_by_frags(hash, start) + end + + # call-seq: + # Date._parse(string, comp = true, limit: 128) -> hash + # + # Returns a hash of values parsed from +string+. + # + # 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. + # + # See argument {limit}[rdoc-ref:Date@Argument+limit]. + # + # Related: Date.parse (returns a \Date object). + def _parse(string, comp = true, limit: 128) + string = string_value(string) + str = string.strip + + # Check limit + if limit && str.length > limit + raise ArgumentError, "string length (#{str.length}) exceeds the limit #{limit}" + end + + date__parse(str, comp) + end + + # 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) + return {} if string.nil? + string = string_value(string) + check_string_limit(string, limit) + + date__iso8601(string) + end + + # date__rfc3339 in date_parse.c + def _rfc3339(string, limit: 128) + return {} if string.nil? + string = string_value(string) + check_string_limit(string, limit) + + date__rfc3339(string) + end + + # date__xmlschema in date_parse.c + def _xmlschema(string, limit: 128) + return {} if string.nil? + string = string_value(string) + check_string_limit(string, limit) + + date__xmlschema(string) + end + + # date__rfc2822 in date_parse.c + def _rfc2822(string, limit: 128) + return {} if string.nil? + string = string_value(string) + check_string_limit(string, limit) + + date__rfc2822(string) + end + alias _rfc822 _rfc2822 + + # 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) + return {} if string.nil? + string = string_value(string) + check_string_limit(string, limit) + + date__httpdate(string) + end + + # 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) + return {} if string.nil? + string = string_value(string) + check_string_limit(string, limit) + + date__jisx0301(string) + end + + # --- Constructor methods --- + + def iso8601(string = JULIAN_EPOCH_DATE, start = DEFAULT_SG, limit: 128) + hash = _iso8601(string, limit: limit) + + new_by_frags(hash, start) + end + + def rfc3339(string = JULIAN_EPOCH_DATETIME, start = DEFAULT_SG, limit: 128) + hash = _rfc3339(string, limit: limit) + + new_by_frags(hash, start) + end + + def xmlschema(string = JULIAN_EPOCH_DATE, start = DEFAULT_SG, limit: 128) + hash = _xmlschema(string, limit: limit) + + new_by_frags(hash, start) + end + + def rfc2822(string = JULIAN_EPOCH_DATETIME_RFC2822, start = DEFAULT_SG, limit: 128) + hash = _rfc2822(string, limit: limit) + + new_by_frags(hash, start) + end + alias rfc822 rfc2822 + + def httpdate(string = JULIAN_EPOCH_DATETIME_HTTPDATE, start = DEFAULT_SG, limit: 128) + hash = _httpdate(string, limit: limit) + + new_by_frags(hash, start) + end + + def jisx0301(string = JULIAN_EPOCH_DATE, start = DEFAULT_SG, limit: 128) + hash = _jisx0301(string, limit: limit) + + new_by_frags(hash, start) + end + + private + + def date__parse(str, comp) + hash = {} + + # Preprocessing: duplicate and replace non-allowed characters. + # Non-TIGHT: Replace [^-+',./:@[:alnum:]\[\]]+ with a single space + str = str.dup.gsub(%r{[^-+',./:@[:alnum:]\[\]]+}, ' ') + + hash[:_comp] = comp + + # Parser invocation (non-TIGHT order) + # Note: C's HAVE_ELEM_P calls check_class(str) every time because + # str is modified by subx after each successful parse. + + # parse_day and parse_time always run (no goto ok). + if have_elem_p?(str, HAVE_ALPHA) + parse_day(str, hash) + end + + if have_elem_p?(str, HAVE_DIGIT) + parse_time(str, hash) + end + + # Date parsers: first success skips the rest (C's "goto ok"). + # In C, all paths converge at ok: for post-processing. + catch(:date_parsed) do + if have_elem_p?(str, HAVE_ALPHA | HAVE_DIGIT) + throw :date_parsed if parse_eu(str, hash) + throw :date_parsed if parse_us(str, hash) + end + + if have_elem_p?(str, HAVE_DIGIT | HAVE_DASH) + throw :date_parsed if parse_iso(str, hash) + end + + if have_elem_p?(str, HAVE_DIGIT | HAVE_DOT) + throw :date_parsed if parse_jis(str, hash) + end + + if have_elem_p?(str, HAVE_ALPHA | HAVE_DIGIT | HAVE_DASH) + throw :date_parsed if parse_vms(str, hash) + end + + if have_elem_p?(str, HAVE_DIGIT | HAVE_SLASH) + throw :date_parsed if parse_sla(str, hash) + end + + if have_elem_p?(str, HAVE_DIGIT | HAVE_DOT) + throw :date_parsed if parse_dot(str, hash) + end + + if have_elem_p?(str, HAVE_DIGIT) + throw :date_parsed if parse_iso2(str, hash) + end + + if have_elem_p?(str, HAVE_DIGIT) + throw :date_parsed if parse_year(str, hash) + end + + if have_elem_p?(str, HAVE_ALPHA) + throw :date_parsed if parse_mon(str, hash) + end + + if have_elem_p?(str, HAVE_DIGIT) + throw :date_parsed if parse_mday(str, hash) + end + + if have_elem_p?(str, HAVE_DIGIT) + throw :date_parsed if parse_ddd(str, hash) + end + end + + # ok: (post-processing — always runs, matching C's ok: label) + if have_elem_p?(str, HAVE_ALPHA) + parse_bc(str, hash) + end + if have_elem_p?(str, HAVE_DIGIT) + parse_frag(str, hash) + end + + apply_comp(hash) + hash + end + + # asctime format with timezone: Sat Aug 28 02:29:34 JST 1999 + def parse_asctime_with_zone(str, hash) + return false unless str =~ /\b(sun|mon|tue|wed|thu|fri|sat)[[:space:]]+(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[[:space:]]+(\d{1,2})[[:space:]]+(\d{2}):(\d{2}):(\d{2})[[:space:]]+(.*?)[[:space:]]+(-?\d+)[[:space:]]*$/i + + wday_str = $1 + mon_str = $2 + mday_str = $3 + hour_str = $4 + min_str = $5 + sec_str = $6 + zone_part = $7 + year_str = $8 + + hash[:wday] = day_num(wday_str) + hash[:mon] = mon_num(mon_str) + hash[:mday] = mday_str.to_i + hash[:hour] = hour_str.to_i + hash[:min] = min_str.to_i + hash[:sec] = sec_str.to_i + + zone_part = zone_part.strip + unless zone_part.empty? + zone = zone_part.gsub(/\s+/, ' ') + hash[:zone] = zone + hash[:offset] = parse_zone_offset(zone) + end + + hash[:_year_str] = year_str + hash[:year] = year_str.to_i + apply_comp(hash) + + true + end + + # asctime format without timezone: Sat Aug 28 02:55:50 1999 + def parse_asctime(str, hash) + return false unless str =~ /\b(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+)\s*$/i + + wday_str = $1 + mon_str = $2 + mday_str = $3 + hour_str = $4 + min_str = $5 + sec_str = $6 + year_str = $7 + + hash[:wday] = day_num(wday_str) + hash[:mon] = mon_num(mon_str) + hash[:mday] = mday_str.to_i + hash[:hour] = hour_str.to_i + hash[:min] = min_str.to_i + hash[:sec] = sec_str.to_i + hash[:_year_str] = year_str + hash[:year] = year_str.to_i + apply_comp(hash) + + true + end + + # HTTP date type 1: "Sat, 03 Feb 2001 00:00:00 GMT" + def httpdate_type1(str, hash) + pattern = /\A\s*(#{ABBR_DAYS_PATTERN})\s*,\s+ + (\d{2})\s+ + (#{ABBR_MONTHS_PATTERN})\s+ + (-?\d{4})\s+ + (\d{2}):(\d{2}):(\d{2})\s+ + (gmt)\s*\z/ix + + match = pattern.match(str) + return false unless match + + hash[:wday] = day_num(match[1]) + hash[:mday] = match[2].to_i + hash[:mon] = mon_num(match[3]) + hash[:year] = match[4].to_i + hash[:hour] = match[5].to_i + hash[:min] = match[6].to_i + hash[:sec] = match[7].to_i + hash[:zone] = match[8] + hash[:offset] = 0 + + true + end + + # HTTP date type 2: "Saturday, 03-Feb-01 00:00:00 GMT" + def httpdate_type2(str, hash) + pattern = /\A\s*(#{DAYS_PATTERN})\s*,\s+ + (\d{2})\s*-\s* + (#{ABBR_MONTHS_PATTERN})\s*-\s* + (\d{2})\s+ + (\d{2}):(\d{2}):(\d{2})\s+ + (gmt)\s*\z/ix + + match = pattern.match(str) + return false unless match + + hash[:wday] = day_num(match[1]) + hash[:mday] = match[2].to_i + hash[:mon] = mon_num(match[3]) + + # Year completion for 2-digit year + year = match[4].to_i + year = comp_year69(year) if year >= 0 && year <= 99 + hash[:year] = year + + hash[:hour] = match[5].to_i + hash[:min] = match[6].to_i + hash[:sec] = match[7].to_i + hash[:zone] = match[8] + hash[:offset] = 0 + + true + end + + # HTTP date type 3: "Sat Feb 3 00:00:00 2001" + def httpdate_type3(str, hash) + pattern = /\A\s*(#{ABBR_DAYS_PATTERN})\s+ + (#{ABBR_MONTHS_PATTERN})\s+ + (\d{1,2})\s+ + (\d{2}):(\d{2}):(\d{2})\s+ + (\d{4})\s*\z/ix + + match = pattern.match(str) + return false unless match + + hash[:wday] = day_num(match[1]) + hash[:mon] = mon_num(match[2]) + hash[:mday] = match[3].to_i + hash[:hour] = match[4].to_i + hash[:min] = match[5].to_i + hash[:sec] = match[6].to_i + hash[:year] = match[7].to_i + + true + end + + # parse_day in date_parse.c. + # Non-TIGHT pattern: \b(sun|mon|tue|wed|thu|fri|sat)[^-/\d\s]* + # The [^-/\d\s]* part consumes trailing characters (e.g., "urday" + # in "Saturday") so they get replaced by subx, but only the + # abbreviation in $1 is used. + def parse_day(str, hash) + m = subx(str, PARSE_DAY_PAT) + return false unless m + + hash[:wday] = day_num(m[1]) + + true + end + + # parse_time in date_parse.c. + # Uses subx to replace the matched time portion with " " so + # subsequent parsers (parse_us, etc.) won't re-match it. + def parse_time(str, hash) + m = subx(str, TIME_PAT) + return false unless m + + time_str = m[1] + zone_str = m[2] + + parse_time_detail(time_str, hash) + + if zone_str && !zone_str.empty? + hash[:zone] = zone_str + hash[:offset] = date_zone_to_diff(zone_str) + end + + true + end + + # parse_ddd in date_parse.c. + def parse_ddd(str, hash) + m = subx(str, PARSE_DDD_PAT) + return false unless m + + sign = m[1] + digits = m[2] + time_digits = m[3] + fraction = m[4] + zone = m[5] + + l = digits.length + + # Branches based on the length of the main number string. + case l + when 2 + if time_digits.nil? && !fraction.nil? + hash[:sec] = digits[-2, 2].to_i + else + hash[:mday] = digits[0, 2].to_i + end + when 4 + if time_digits.nil? && !fraction.nil? + hash[:sec] = digits[-2, 2].to_i + hash[:min] = digits[-4, 2].to_i + else + hash[:mon] = digits[0, 2].to_i + hash[:mday] = digits[2, 2].to_i + end + when 6 + if time_digits.nil? && !fraction.nil? + hash[:sec] = digits[-2, 2].to_i + hash[:min] = digits[-4, 2].to_i + hash[:hour] = digits[-6, 2].to_i + else + y = digits[0, 2].to_i + y = -y if sign == '-' + hash[:year] = y + hash[:mon] = digits[2, 2].to_i + hash[:mday] = digits[4, 2].to_i + hash[:_year_str] = digits[0, 2] # year completion + end + when 8, 10, 12, 14 + if time_digits.nil? && !fraction.nil? + # Interpreted as time + hash[:sec] = digits[-2, 2].to_i + hash[:min] = digits[-4, 2].to_i + hash[:hour] = digits[-6, 2].to_i + hash[:mday] = digits[-8, 2].to_i + hash[:mon] = digits[-10, 2].to_i if l >= 10 + if l == 12 + y = digits[-12, 2].to_i + y = -y if sign == '-' + hash[:year] = y + hash[:_year_str] = digits[-12, 2] + elsif l == 14 + y = digits[-14, 4].to_i + y = -y if sign == '-' + hash[:year] = y + hash[:_comp] = false + end + else + # Interpret as date + y = digits[0, 4].to_i + y = -y if sign == '-' + hash[:year] = y + hash[:mon] = digits[4, 2].to_i + hash[:mday] = digits[6, 2].to_i + hash[:hour] = digits[8, 2].to_i if l >= 10 + hash[:min] = digits[10, 2].to_i if l >= 12 + hash[:sec] = digits[12, 2].to_i if l >= 14 + hash[:_comp] = false + end + when 3 + if time_digits.nil? && !fraction.nil? + hash[:sec] = digits[-2, 2].to_i + hash[:min] = digits[-3, 1].to_i + else + hash[:yday] = digits[0, 3].to_i + end + when 5 + if time_digits.nil? && !fraction.nil? + hash[:sec] = digits[-2, 2].to_i + hash[:min] = digits[-4, 2].to_i + hash[:hour] = digits[-5, 1].to_i + else + y = digits[0, 2].to_i + y = -y if sign == '-' + hash[:year] = y + hash[:yday] = digits[2, 3].to_i + hash[:_year_str] = digits[0, 2] + end + when 7 + if time_digits.nil? && !fraction.nil? + hash[:sec] = digits[-2, 2].to_i + hash[:min] = digits[-4, 2].to_i + hash[:hour] = digits[-6, 2].to_i + hash[:mday] = digits[-7, 1].to_i + else + y = digits[0, 4].to_i + y = -y if sign == '-' + hash[:year] = y + hash[:yday] = digits[4, 3].to_i + # No need to complete because it is a 4-digit year + end + end + + # Processing time portion + if time_digits && !time_digits.empty? + tl = time_digits.length + if !fraction.nil? + # Interpreted as time + case tl + when 2, 4, 6 + hash[:sec] = time_digits[-2, 2].to_i + hash[:min] = time_digits[-4, 2].to_i if tl >= 4 + hash[:hour] = time_digits[-6, 2].to_i if tl >= 6 + end + else + # Interpreted as time + case tl + when 2, 4, 6 + hash[:hour] = time_digits[0, 2].to_i + hash[:min] = time_digits[2, 2].to_i if tl >= 4 + hash[:sec] = time_digits[4, 2].to_i if tl >= 6 + end + end + end + + # Handling fractional seconds + if fraction && !fraction.empty? + hash[:sec_fraction] = Rational(fraction.to_i, 10 ** fraction.length) + end + + # Handling time zone + if zone && !zone.empty? + if zone[0] == '[' + # Bracket-enclosed zone: C's parse_ddd_cb special handling. + # Strip '[' and ']', then check for ':' separator. + inner = zone[1..-2] # content between [ and ] + colon_pos = inner.index(':') + if colon_pos + # e.g., "[-5:EST]" → zone_name="EST", offset_str="-5:" + # C: zone = part after ':', s5 = part from start to after ':' + zone_name = inner[(colon_pos + 1)..] + offset_str = inner[0, colon_pos + 1] # includes ':' + else + # e.g., "[-9]" → zone_name="-9", offset_str="-9" + # e.g., "[9]" → zone_name="9", offset_str="+9" (digit→prepend '+') + zone_name = inner + if inner[0] && inner[0] =~ /\d/ + offset_str = "+" + zone_name + else + offset_str = zone_name + end + end + hash[:zone] = zone_name + hash[:offset] = date_zone_to_diff(offset_str) + else + # Non-bracket zone: just set zone. + # Offset will be resolved in apply_comp if not already set. + hash[:zone] = zone + hash[:offset] = date_zone_to_diff(zone) + end + end + + true + end + + # Parse $1 (time string) further and set hash to hour/min/sec/sec_fraction. + # + # Internal pattern: + # $1 hour + # $2 min (colon format) + # $3 sec (colon format) + # $4 frac ([,.]\d*) + # $5 min (h format) + # $6 sec (h format) + # $7 am/pm (a or p) + def parse_time_detail(time_str, hash) + return unless time_str =~ TIME_DETAIL_PAT + + hour = $1.to_i + min_colon = $2 + sec_colon = $3 + frac = $4 # "[,.] number string" or nil + min_h = $5 + sec_h = $6 + ampm = $7 + + if min_colon + # Branch A: HH:MM[:SS[.frac]] + hash[:hour] = hour + hash[:min] = min_colon.to_i + if sec_colon + hash[:sec] = sec_colon.to_i + if frac && frac.length > 1 + # Since frac is a "[,.] number string", the first character (delimiter) is omitted. + frac_digits = frac[1..] + hash[:sec_fraction] = Rational(frac_digits.to_i, 10 ** frac_digits.length) + end + end + elsif min_h + # Branch B: HHh[MMm[SSs]](with min) + hash[:hour] = hour + hash[:min] = min_h.to_i + hash[:sec] = sec_h.to_i if sec_h + elsif time_str.match?(/h/i) + # Branch B: Only HHh (no min/sec) + hash[:hour] = hour + elsif ampm + # Branch C: Only AM/PM => Set only hour (converted to AM/PM below) + hash[:hour] = hour + end + + # AM/PM conversion + if ampm + h = hash[:hour] || hour + if ampm.downcase == 'p' && h != 12 + hash[:hour] = h + 12 + elsif ampm.downcase == 'a' && h == 12 + hash[:hour] = 0 + end + end + end + + # parse_era in date_parse.c. + def parse_era(str, hash) + if str =~ ERA1_PAT + hash[:bc] = false + return true + end + + if str =~ ERA2_PAT + hash[:bc] = $1.downcase.delete('.') != 'ce' + return true + end + + false + end + + # parse_eu in date_parse.c. + def parse_eu(str, hash) + m = subx(str, PARSE_EU_PAT) + return false unless m + + mday_str = m[1] + mon_str = m[2] + era_str = m[3] + year_str = m[4] + + # Determine bc flag from era. + # AD/A.D./CE/C.E. => false, BC/B.C./BCE/B.C.E. => true + bc = if era_str + era_str.downcase.delete('.') !~ /\A(ad|ce)\z/ + else + false + end + + # Normalize y/m/d and set to hash in s3e. + # 'mon' is converted to an Integer using 'mon_num' and then passed. + s3e(hash, year_str, mon_num(mon_str), mday_str, bc) + + true + end + + # parse_us in date_parse.c. + def parse_us(str, hash) + m = subx(str, PARSE_US_PAT) + return false unless m + + mon_str = m[1] + mday_str = m[2] + era_str = m[3] + year_str = m[4] + + # Determine bc flag from era (same logic as parse_eu). + bc = if era_str + era_str.downcase.delete('.') !~ /\A(ad|ce)\z/ + else + false + end + + # Normalize y/m/d and set to hash using s3e. + # Difference from parse_eu: mon=$1, mday=$2 (only the $ numbers are swapped). + s3e(hash, year_str, mon_num(mon_str), mday_str, bc) + + true + end + + # parse_iso in date_parse.c + def parse_iso(str, hash) + m = subx(str, PARSE_ISO_PAT) + return false unless m + + # Normalize y/m/d and set to hash in s3e. + # bc is always false (there is no era symbol in ISO format). + s3e(hash, m[1], m[2], m[3], false) + + true + end + + # parse_iso2 in date_parse.c + def parse_iso2(str, hash) + return true if parse_iso21(str, hash) + return true if parse_iso22(str, hash) + return true if parse_iso23(str, hash) + return true if parse_iso24(str, hash) + return true if parse_iso25(str, hash) + return true if parse_iso26(str, hash) + + false + end + + def parse_iso21(str, hash) + m = subx(str, PARSE_ISO21_PAT) + return false unless m + + hash[:cwyear] = m[1].to_i if m[1] + hash[:cweek] = m[2].to_i + hash[:cwday] = m[3].to_i if m[3] + + true + end + + def parse_iso22(str, hash) + m = subx(str, PARSE_ISO22_PAT) + return false unless m + + hash[:cwday] = m[1].to_i + + true + end + + def parse_iso23(str, hash) + m = subx(str, PARSE_ISO23_PAT) + return false unless m + + hash[:mon] = m[1].to_i if m[1] + hash[:mday] = m[2].to_i + + true + end + + def parse_iso24(str, hash) + m = subx(str, PARSE_ISO24_PAT) + return false unless m + + hash[:mon] = m[1].to_i + hash[:mday] = m[2].to_i if m[2] + + true + end + + def parse_iso25(str, hash) + # Skip if exclude pattern matches (uses match, not subx). + return false if str =~ PARSE_ISO25_PAT0 + + m = subx(str, PARSE_ISO25_PAT) + return false unless m + + hash[:year] = m[1].to_i + hash[:yday] = m[2].to_i + + true + end + + def parse_iso26(str, hash) + # Skip if exclude pattern matches (uses match, not subx). + return false if str =~ PARSE_ISO26_PAT0 + + m = subx(str, PARSE_ISO26_PAT) + return false unless m + + hash[:yday] = m[1].to_i + + true + end + + # parse_jis in date_parse.c + def parse_jis(str, hash) + m = subx(str, PARSE_JIS_PAT) + return false unless m + + era = m[1].upcase + year = m[2].to_i + mon = m[3].to_i + mday = m[4].to_i + + # Convert the era symbol and year number to Gregorian calendar + # and set it to hash. + hash[:year] = gengo(era) + year + hash[:mon] = mon + hash[:mday] = mday + + true + end + + # parse_vms in date_parse.c + def parse_vms(str, hash) + return true if parse_vms11(str, hash) + return true if parse_vms12(str, hash) + + false + end + + def parse_vms11(str, hash) + m = subx(str, PARSE_VMS11_PAT) + return false unless m + + mday_str = m[1] + mon_str = m[2] + year_str = m[3] + + # Normalize y/m/d and set to hash in s3e. + s3e(hash, year_str, mon_num(mon_str), mday_str, false) + + true + end + + def parse_vms12(str, hash) + m = subx(str, PARSE_VMS12_PAT) + return false unless m + + mon_str = m[1] + mday_str = m[2] + year_str = m[3] + + # Normalize y/m/d and set to hash in s3e. + s3e(hash, year_str, mon_num(mon_str), mday_str, false) + + true + end + + # parse_sla in date_parse.c + def parse_sla(str, hash) + m = subx(str, PARSE_SLA_PAT) + return false unless m + + # Normalize y/m/d and set to hash in s3e. + # bc is always false. + s3e(hash, m[1], m[2], m[3], false) + + true + end + + # parse_dot in date_parse.c + def parse_dot(str, hash) + m = subx(str, PARSE_DOT_PAT) + return false unless m + + # Normalize y/m/d and set to hash in s3e. + # bc is always false. + s3e(hash, m[1], m[2], m[3], false) + + true + end + + # parse_year in date_parse.c + def parse_year(str, hash) + m = subx(str, PARSE_YEAR_PAT) + return false unless m + + hash[:year] = m[1].to_i + + true + end + + # parse_mon in date_parse.c + def parse_mon(str, hash) + m = subx(str, PARSE_MON_PAT) + return false unless m + + hash[:mon] = mon_num(m[1]) + + true + end + + # parse_mday in date_parse.c + def parse_mday(str, hash) + m = subx(str, PARSE_MDAY_PAT) + return false unless m + + hash[:mday] = m[1].to_i + + true + end + + # parse_bc in date_parse.c (non-TIGHT post-processing). + # Matches standalone BC/BCE/B.C./B.C.E. and sets _bc flag. + def parse_bc(str, hash) + m = subx(str, PARSE_BC_PAT) + return false unless m + + hash[:_bc] = true + + true + end + + # parse_frag in date_parse.c (non-TIGHT post-processing). + # If the remaining string (after all other parsers have consumed + # their portions) is a standalone 1-2 digit number: + # - If we have hour but no mday, and the number is 1-31, set mday + # - If we have mday but no hour, and the number is 0-24, set hour + def parse_frag(str, hash) + m = subx(str, PARSE_FRAG_PAT) + return false unless m + + n = m[1].to_i + + if hash.key?(:hour) && !hash.key?(:mday) + hash[:mday] = n if n >= 1 && n <= 31 + end + if hash.key?(:mday) && !hash.key?(:hour) + hash[:hour] = n if n >= 0 && n <= 24 + end + + true + end + + # Helper: Convert day name to number (0=Sunday, 6=Saturday) + def day_num(day_name) + abbr_days = %w[sun mon tue wed thu fri sat] + abbr_days.index(day_name[0, 3].downcase) || 0 + end + + # Helper: Convert month name to number (1=January, 12=December) + def mon_num(month_name) + abbr_months = %w[jan feb mar apr may jun jul aug sep oct nov dec] + (abbr_months.index(month_name[0, 3].downcase) || 0) + 1 + end + + # ISO 8601 extended datetime: 2001-02-03T04:05:06+09:00 + def iso8601_ext_datetime(str, hash) + pattern = /\A\s* + (?: + ([-+]?\d{2,}|-)-(\d{2})?(?:-(\d{2}))?| # YYYY-MM-DD or --MM-DD + ([-+]?\d{2,})?-(\d{3})| # YYYY-DDD + (\d{4}|\d{2})?-w(\d{2})-(\d)| # YYYY-Www-D + -w-(\d) # -W-D + ) + (?:t + (\d{2}):(\d{2})(?::(\d{2})(?:[,.](\d+))?)? # HH:MM:SS.fraction + (z|[-+]\d{2}(?::?\d{2})?)? # timezone + )? + \s*\z/ix + + match = pattern.match(str) + return false unless match + + # Calendar date (YYYY-MM-DD) + if match[1] + unless match[1] == '-' + year = match[1].to_i + # Complete 2-digit year + year = comp_year69(year) if match[1].length < 4 + hash[:year] = year + end + hash[:mon] = match[2].to_i if match[2] + hash[:mday] = match[3].to_i if match[3] + # Ordinal date (YYYY-DDD) + elsif match[5] + if match[4] + year = match[4].to_i + year = comp_year69(year) if match[4].length < 4 + hash[:year] = year + end + hash[:yday] = match[5].to_i + # Week date (YYYY-Www-D) + elsif match[8] + if match[6] + year = match[6].to_i + year = comp_year69(year) if match[6].length < 4 + hash[:cwyear] = year + end + hash[:cweek] = match[7].to_i + hash[:cwday] = match[8].to_i + # Week day only (-W-D) + elsif match[9] + hash[:cwday] = match[9].to_i + end + + # Time + if match[10] + hash[:hour] = match[10].to_i + hash[:min] = match[11].to_i + hash[:sec] = match[12].to_i if match[12] + hash[:sec_fraction] = parse_fraction(match[13]) if match[13] + end + + # Timezone + if match[14] + hash[:zone] = match[14] + hash[:offset] = parse_zone_offset(match[14]) + end + + true + end + + # ISO 8601 basic datetime: 20010203T040506 + def iso8601_bas_datetime(str, hash) + # Try full basic datetime: YYYYMMDD or YYMMDD + pattern = /\A\s* + ([-+]?(?:\d{4}|\d{2})|--) # Year (YYYY, YY, --, or signed) + (\d{2}|-) # Month (MM or -) + (\d{2}) # Day (DD) + (?:t? + (\d{2})(\d{2}) # Hour and minute (HHMM) + (?:(\d{2}) # Second (SS) + (?:[,.](\d+))? # Fraction + )? + (z|[-+]\d{2}(?:\d{2})?)? # Timezone + )? + \s*\z/ix + + match = pattern.match(str) + if match + # Calendar date + unless match[1] == '--' + year = match[1].to_i + year = comp_year69(year) if match[1].length == 2 && match[1] !~ /^[-+]/ + hash[:year] = year + end + hash[:mon] = match[2].to_i unless match[2] == '-' + hash[:mday] = match[3].to_i + + # Time + if match[4] + hash[:hour] = match[4].to_i + hash[:min] = match[5].to_i + hash[:sec] = match[6].to_i if match[6] + hash[:sec_fraction] = parse_fraction(match[7]) if match[7] + end + + # Timezone + if match[8] + hash[:zone] = match[8] + hash[:offset] = parse_zone_offset(match[8]) + end + + return true + end + + # Try ordinal date: YYYYDDD or YYDDD + pattern = /\A\s* + ([-+]?(?:\d{4}|\d{2})) # Year + (\d{3}) # Day of year + (?:t? + (\d{2})(\d{2}) # Hour and minute + (?:(\d{2}) # Second + (?:[,.](\d+))? # Fraction + )? + (z|[-+]\d{2}(?:\d{2})?)? # Timezone + )? + \s*\z/ix + + match = pattern.match(str) + if match + year = match[1].to_i + year = comp_year69(year) if match[1].length == 2 && match[1] !~ /^[-+]/ + hash[:year] = year + hash[:yday] = match[2].to_i + + # Time + if match[3] + hash[:hour] = match[3].to_i + hash[:min] = match[4].to_i + hash[:sec] = match[5].to_i if match[5] + hash[:sec_fraction] = parse_fraction(match[6]) if match[6] + end + + # Timezone + if match[7] + hash[:zone] = match[7] + hash[:offset] = parse_zone_offset(match[7]) + end + + return true + end + + # Try -DDD (ordinal without year) + pattern = /\A\s* + -(\d{3}) # Day of year + (?:t? + (\d{2})(\d{2}) # Hour and minute + (?:(\d{2}) # Second + (?:[,.](\d+))? # Fraction + )? + (z|[-+]\d{2}(?:\d{2})?)? # Timezone + )? + \s*\z/ix + + match = pattern.match(str) + if match + hash[:yday] = match[1].to_i + + # Time + if match[2] + hash[:hour] = match[2].to_i + hash[:min] = match[3].to_i + hash[:sec] = match[4].to_i if match[4] + hash[:sec_fraction] = parse_fraction(match[5]) if match[5] + end + + # Timezone + if match[6] + hash[:zone] = match[6] + hash[:offset] = parse_zone_offset(match[6]) + end + + return true + end + + # Try week date: YYYYWwwD or YYWwwD + pattern = /\A\s* + (\d{4}|\d{2}) # Year + w(\d{2}) # Week + (\d) # Day of week + (?:t? + (\d{2})(\d{2}) # Hour and minute + (?:(\d{2}) # Second + (?:[,.](\d+))? # Fraction + )? + (z|[-+]\d{2}(?:\d{2})?)? # Timezone + )? + \s*\z/ix + + match = pattern.match(str) + if match + year = match[1].to_i + year = comp_year69(year) if match[1].length == 2 + hash[:cwyear] = year + hash[:cweek] = match[2].to_i + hash[:cwday] = match[3].to_i + + # Time + if match[4] + hash[:hour] = match[4].to_i + hash[:min] = match[5].to_i + hash[:sec] = match[6].to_i if match[6] + hash[:sec_fraction] = parse_fraction(match[7]) if match[7] + end + + # Timezone + if match[8] + hash[:zone] = match[8] + hash[:offset] = parse_zone_offset(match[8]) + end + + return true + end + + # Try -WwwD (week date without year) + pattern = /\A\s* + -w(\d{2}) # Week + (\d) # Day of week + (?:t? + (\d{2})(\d{2}) # Hour and minute + (?:(\d{2}) # Second + (?:[,.](\d+))? # Fraction + )? + (z|[-+]\d{2}(?:\d{2})?)? # Timezone + )? + \s*\z/ix + + match = pattern.match(str) + if match + hash[:cweek] = match[1].to_i + hash[:cwday] = match[2].to_i + + # Time + if match[3] + hash[:hour] = match[3].to_i + hash[:min] = match[4].to_i + hash[:sec] = match[5].to_i if match[5] + hash[:sec_fraction] = parse_fraction(match[6]) if match[6] + end + + # Timezone + if match[7] + hash[:zone] = match[7] + hash[:offset] = parse_zone_offset(match[7]) + end + + return true + end + + # Try -W-D (day of week only) + pattern = /\A\s* + -w-(\d) # Day of week + (?:t? + (\d{2})(\d{2}) # Hour and minute + (?:(\d{2}) # Second + (?:[,.](\d+))? # Fraction + )? + (z|[-+]\d{2}(?:\d{2})?)? # Timezone + )? + \s*\z/ix + + match = pattern.match(str) + if match + hash[:cwday] = match[1].to_i + + # Time + if match[2] + hash[:hour] = match[2].to_i + hash[:min] = match[3].to_i + hash[:sec] = match[4].to_i if match[4] + hash[:sec_fraction] = parse_fraction(match[5]) if match[5] + end + + # Timezone + if match[6] + hash[:zone] = match[6] + hash[:offset] = parse_zone_offset(match[6]) + end + + return true + end + + false + end + + # ISO 8601 extended time: 04:05:06+09:00 + def iso8601_ext_time(str, hash) + # Pattern: HH:MM:SS.fraction or HH:MM:SS,fraction + pattern = /\A\s*(\d{2}):(\d{2})(?::(\d{2})(?:[,.](\d+))?)?(z|[-+]\d{2}(?::?\d{2})?)?\s*\z/ix + + match = pattern.match(str) + return false unless match + + hash[:hour] = match[1].to_i + hash[:min] = match[2].to_i + hash[:sec] = match[3].to_i if match[3] + hash[:sec_fraction] = parse_fraction(match[4]) if match[4] + + if match[5] + hash[:zone] = match[5] + hash[:offset] = parse_zone_offset(match[5]) + end + + true + end + + # ISO 8601 basic time: 040506 + def iso8601_bas_time(str, hash) + # Pattern: HHMMSS.fraction or HHMMSS,fraction + pattern = /\A\s*(\d{2})(\d{2})(?:(\d{2})(?:[,.](\d+))?)?(z|[-+]\d{2}(?:\d{2})?)?\s*\z/ix + + match = pattern.match(str) + return false unless match + + hash[:hour] = match[1].to_i + hash[:min] = match[2].to_i + hash[:sec] = match[3].to_i if match[3] + hash[:sec_fraction] = parse_fraction(match[4]) if match[4] + + if match[5] + hash[:zone] = match[5] + hash[:offset] = parse_zone_offset(match[5]) + end + + true + end + + # Parse fractional seconds + def parse_fraction(frac_str) + return nil unless frac_str + Rational(frac_str.to_i, 10 ** frac_str.length) + end + + # Parse timezone offset (Z, +09:00, -0500, etc.) + def parse_zone_offset(zone_str) + return nil if zone_str.nil? || zone_str.empty? + + zone = zone_str.strip + + # Handle [+9] or [-9] or [9 ] format (brackets around offset) + if zone =~ /^\[(.*)\]$/ + zone = $1.strip + end + + # Handle Z (UTC) + return 0 if zone.upcase == 'Z' + + # Handle unsigned numeric offset: 9, 09 (assume positive) + if zone =~ /^(\d{1,2})$/ + hours = $1.to_i + return hours * HOUR_IN_SECONDS + end + + # Handle simple numeric offsets with sign: +9, -9, +09, -05, etc. + if zone =~ /^([-+])(\d{1,2})$/ + sign = $1 == '-' ? -1 : 1 + hours = $2.to_i + return sign * (hours * HOUR_IN_SECONDS) + end + + # Handle +09:00, -05:30 format (with colon) + if zone =~ /^([-+])(\d{2}):(\d{2})$/ + sign = $1 == '-' ? -1 : 1 + hours = $2.to_i + minutes = $3.to_i + return sign * (hours * HOUR_IN_SECONDS + minutes * MINUTE_IN_SECONDS) + end + + # Handle +0900, -0500 format (4 digits, no colon) + if zone =~ /^([-+])(\d{4})$/ + sign = $1 == '-' ? -1 : 1 + hours = $2[0, 2].to_i + minutes = $2[2, 2].to_i + return sign * (hours * HOUR_IN_SECONDS + minutes * MINUTE_IN_SECONDS) + end + + # Handle +0900 format (4 digits without colon) + if zone =~ /^([-+])(\d{4})$/ + sign = $1 == '-' ? -1 : 1 + hours = $2[0, 2].to_i + minutes = $2[2, 2].to_i + return sign * (hours * HOUR_IN_SECONDS + minutes * MINUTE_IN_SECONDS) + end + + # Handle fractional hours: +9.5, -5.5 + if zone =~ /^([-+])(\d+)[.,](\d+)$/ + sign = $1 == '-' ? -1 : 1 + hours = $2.to_i + fraction = "0.#{$3}".to_f + return sign * ((hours + fraction) * HOUR_IN_SECONDS).to_i + end + + # Handle GMT+9, GMT-5, etc. + if zone =~ /^(?:gmt|utc)?([-+])(\d{1,2})(?::?(\d{2}))?(?::?(\d{2}))?$/i + sign = $1 == '-' ? -1 : 1 + hours = $2.to_i + minutes = $3 ? $3.to_i : 0 + seconds = $4 ? $4.to_i : 0 + return sign * (hours * HOUR_IN_SECONDS + minutes * MINUTE_IN_SECONDS + seconds) + end + + # Known timezone abbreviations + zone_offsets = { + 'JST' => 9 * HOUR_IN_SECONDS, + 'GMT' => 0, + 'UTC' => 0, + 'UT' => 0, + 'EST' => -5 * HOUR_IN_SECONDS, + 'EDT' => -4 * HOUR_IN_SECONDS, + 'CST' => -6 * HOUR_IN_SECONDS, + 'CDT' => -5 * HOUR_IN_SECONDS, + 'MST' => -7 * HOUR_IN_SECONDS, + 'MDT' => -6 * HOUR_IN_SECONDS, + 'PST' => -8 * HOUR_IN_SECONDS, + 'PDT' => -7 * HOUR_IN_SECONDS, + 'AEST' => 10 * HOUR_IN_SECONDS, + 'MET DST' => 2 * HOUR_IN_SECONDS, + 'GMT STANDARD TIME' => 0, + 'MOUNTAIN STANDARD TIME' => -7 * HOUR_IN_SECONDS, + 'MOUNTAIN DAYLIGHT TIME' => -6 * HOUR_IN_SECONDS, + 'MEXICO STANDARD TIME' => -6 * HOUR_IN_SECONDS, + 'E. AUSTRALIA STANDARD TIME' => 10 * HOUR_IN_SECONDS, + 'W. CENTRAL AFRICA STANDARD TIME' => 1 * HOUR_IN_SECONDS, + } + + # Handle military timezones (single letters A-Z except J) + if zone =~ /^([A-Z])$/i + letter = zone.upcase + return 0 if letter == 'Z' + return nil if letter == 'J' # J is not used + + if letter <= 'I' + # A-I: +1 to +9 + offset = letter.ord - 'A'.ord + 1 + elsif letter >= 'K' && letter <= 'M' + # K-M: +10 to +12 (skip J) + offset = letter.ord - 'A'.ord # K is 10th letter (ord-'A'=10) + elsif letter >= 'N' && letter <= 'Y' + # N-Y: -1 to -12 + offset = -(letter.ord - 'N'.ord + 1) + else + return nil + end + + return offset * HOUR_IN_SECONDS + end + + # Normalize zone string for lookup + zone_upper = zone.gsub(/\s+/, ' ').upcase + zone_offsets[zone_upper] + end + + # JIS X 0301 format: H13.02.03 or H13.02.03T04:05:06 + def parse_jisx0301_fmt(str, hash) + # Pattern: [Era]YY.MM.DD[T]HH:MM:SS[.fraction][timezone] + # Era initials: M, T, S, H, R (or none for ISO 8601 fallback) + pattern = /\A\s* + ([#{JISX0301_ERA_INITIALS}])? # Era (optional) + (\d{2})\.(\d{2})\.(\d{2}) # YY.MM.DD + (?:t # Time separator (optional) + (?: + (\d{2}):(\d{2}) # HH:MM + (?::(\d{2}) # :SS (optional) + (?:[,.](\d*))? # .fraction (optional) + )? + (z|[-+]\d{2}(?::?\d{2})?)? # timezone (optional) + )? + )? + \s*\z/ix + + match = pattern.match(str) + return false unless match + + # Parse era and year + era_char = match[1] ? match[1].upcase : JISX0301_DEFAULT_ERA + era_year = match[2].to_i + + # Convert era year to gregorian year + era_start = gengo(era_char) + hash[:year] = era_start + era_year + + # Parse month and day + hash[:mon] = match[3].to_i + hash[:mday] = match[4].to_i + + # Parse time (if present) + if match[5] + hash[:hour] = match[5].to_i + hash[:min] = match[6].to_i if match[6] + hash[:sec] = match[7].to_i if match[7] + hash[:sec_fraction] = parse_fraction(match[8]) if match[8] + end + + # Parse timezone (if present) + if match[9] + hash[:zone] = match[9] + hash[:offset] = parse_zone_offset(match[9]) + end + + true + end + + # Convert era character to year offset + def gengo(era_char) + case era_char.upcase + when 'M' then 1867 # Meiji + when 'T' then 1911 # Taisho + when 'S' then 1925 # Showa + when 'H' then 1988 # Heisei + when 'R' then 2018 # Reiwa + else 0 + end + end + + # Post-processing: matches C's date__parse post-processing after ok: label. + # + # 1. _bc handling: negate year and cwyear (year = 1 - year) + # 2. _comp handling: complete 2-digit year/cwyear to 4-digit (69-99 → 1900s, 0-68 → 2000s) + # 3. zone → offset conversion + # 4. Clean up internal keys + def apply_comp(hash) + # _bc: del_hash("_bc") — read and delete + bc = hash.delete(:_bc) + if bc + if hash.key?(:cwyear) + hash[:cwyear] = 1 - hash[:cwyear] + end + if hash.key?(:year) + hash[:year] = 1 - hash[:year] + end + end + + # _comp: del_hash("_comp") — read and delete + comp = hash.delete(:_comp) + if comp + if hash.key?(:cwyear) + y = hash[:cwyear] + if y >= 0 && y <= 99 + hash[:cwyear] = y >= 69 ? y + 1900 : y + 2000 + end + end + if hash.key?(:year) + y = hash[:year] + if y >= 0 && y <= 99 + hash[:year] = y >= 69 ? y + 1900 : y + 2000 + end + end + end + + # zone → offset conversion + if hash.key?(:zone) && !hash.key?(:offset) + hash[:offset] = date_zone_to_diff(hash[:zone]) + end + + # Clean up internal keys + hash.delete(:_year_str) + end + + # s3e in date_parse.c. + # y, m, and d are Strings or nil. m can also be an Integer (convert with to_s). + # bc is a Boolean. + # + # This method normalizes the year, mon, and mday from the combination of y, m, and + # d and writes them to a hash. + # The sorting logic operates in the following order of priority: + # + # Phase 1: Argument rotation and promotion + # - y and m are available, but d is nil => Rotate because it is a pair (mon, mday) + # - y is nil and d is long (>2 digits) or starts with an apostrophe => Promote d to y + # - If y has a leading character other than a digit, extract only the numeric portion, and if there is a remainder, add it to d + # + # Phase 2: Sort m and d + # - m starts with an apostrophe or its length is >2 => US->BE sort (y,m,d)=(m,d,y) + # - d starts with an apostrophe or its length is >2 => Swap (y,d) + # + # Phase 3: Write to hash + # - Extract the sign and digits from y and set them to year + # If signed or the number of digits is >2, write _comp = false + # - Extract the number from m and set it to mon + # - Extract the number from d and set it to mday + # - If bc is true, write _bc = true + def s3e(hash, y, m, d, bc) + # Candidates for _comp. If nil, do not write. + c = nil + + # If m is not a string, use to_s (parse_eu/parse_us passes the Integer returned by mon_num) + m = m.to_s unless m.nil? || m.is_a?(String) + + # ---------------------------------------------------------- + # Phase 1: Argument reordering + # ---------------------------------------------------------- + + # If we have y and m, but d is nil, it's actually a (mon, mday) pair, so we rotate it. + # (y, m, d) = (nil, y, m) + if !y.nil? && !m.nil? && d.nil? + y, m, d = nil, y, m + end + + # If y is nil and d exists, if d is long or begins with an apostrophe, it is promoted to y + if y.nil? + if !d.nil? && d.length > 2 + y = d + d = nil + end + if !d.nil? && d.length > 0 && d[0] == "'" + y = d + d = nil + end + end + + # If y has a leading character other than a sign or a number, skip it and + # extract only the numeric part. If there are any characters remaining after + # the extracted numeric string, swap y and d, and set the numeric part to d. + unless y.nil? + pos = 0 + pos += 1 while pos < y.length && !issign?(y[pos]) && !y[pos].match?(/\d/) + + unless pos >= y.length # no_date + bp = pos + pos += 1 if pos < y.length && issign?(y[pos]) + span = digit_span(y[pos..]) + ep = pos + span + + if ep < y.length + # There is a letter after the number string => exchange (y, d) + y, d = d, y[bp...ep] + end + end + end + + # ---------------------------------------------------------- + # Phase 2: Rearrange m and d + # ---------------------------------------------------------- + + # m starts with an apostrophe or length > 2 => US => BE sort + # (y, m, d) = (m, d, y) + if !m.nil? && (m[0] == "'" || m.length > 2) + y, m, d = m, d, y + end + + # d begins with an apostrophe or length > 2 => exchange (y, d) + if !d.nil? && (d[0] == "'" || d.length > 2) + y, d = d, y + end + + # ---------------------------------------------------------- + # Phase 3: Write to hash + # ---------------------------------------------------------- + + # year: Extract the sign and digit from y and set + unless y.nil? + pos = 0 + pos += 1 while pos < y.length && !issign?(y[pos]) && !y[pos].match?(/\d/) + + unless pos >= y.length # no_year + bp = pos + sign = false + if pos < y.length && issign?(y[pos]) + sign = true + pos += 1 + end + + c = false if sign # Signed => _comp = false + span = digit_span(y[pos..]) + c = false if span > 2 # Number of digits > 2 => _comp = false + + num_str = y[bp, (pos - bp) + span] # sign + number part + hash[:year] = num_str.to_i + end + end + + hash[:_bc] = true if bc + + # mon: Extract and set a number from m + unless m.nil? + pos = 0 + pos += 1 while pos < m.length && !m[pos].match?(/\d/) + + unless pos >= m.length # no_month + span = digit_span(m[pos..]) + hash[:mon] = m[pos, span].to_i + end + end + + # mday: Extract and set numbers from d + unless d.nil? + pos = 0 + pos += 1 while pos < d.length && !d[pos].match?(/\d/) + + unless pos >= d.length # no_mday + span = digit_span(d[pos..]) + hash[:mday] = d[pos, span].to_i + end + end + + # _comp is written only if it is explicitly false + hash[:_comp] = false unless c.nil? + end + + # issign macro in date_parse.c. + def issign?(c) + c == '-' || c == '+' + end + + # digit_span in date_parse.c. + # Returns the length of the first consecutive digit in the string 's'. + def digit_span(s) + i = 0 + i += 1 while i < s.length && s[i].match?(/\d/) + + i + end + + # date_zone_to_diff in date_parse.c. + # Returns the number of seconds since UTC from a time zone name or offset string. + # Returns nil if no match occurs. + # + # Supported input types: + # 1. Zone names: "EST", "JST", "Eastern", "Central Pacific", ... + # 2. Suffixes: "Eastern standard time", "EST dst", ... + # "standard time" => As is + # "daylight time" / "dst" => Set offset to +3600 + # 3. Numeric offset: "+09:00", "-0530", "+9", "GMT+09:00", ... + # 4. Fractional time offset: "+9.5" (=+09:30), "+5.50" (=+05:30), ... + def date_zone_to_diff(str) + return nil if str.nil? || str.empty? + + s = str.dup + dst = false + + # Suffix removal: "time", "standard", "daylight", "dst" + w = str_end_with_word(s, "time") + if w > 0 + s = s[0, s.length - w] + + w2 = str_end_with_word(s, "standard") + if w2 > 0 + s = s[0, s.length - w2] + else + w2 = str_end_with_word(s, "daylight") + if w2 > 0 + s = s[0, s.length - w2] + dst = true + else + # "time" alone is not enough, so return + s = str.dup + end + end + else + w = str_end_with_word(s, "dst") + if w > 0 + s = s[0, s.length - w] + dst = true + end + end + + # --- zonetab search --- + # Normalize consecutive spaces into a single space before searching + zn = shrink_space(s) + z_offset = ZONE_TABLE[zn.downcase] + + if z_offset + z_offset += 3600 if dst + return z_offset + end + + # --- Parse numeric offsets --- + # Remove "GMT" and "UTC" prefixes + if zn.length > 3 && zn[0, 3].downcase =~ /\A(gmt|utc)\z/ + zn = zn[3..] + end + + # If there is no sign, it is not treated as a numeric offset + return nil if zn.empty? || (zn[0] != '+' && zn[0] != '-') + + sign = zn[0] == '-' ? -1 : 1 + zn = zn[1..] + return nil if zn.empty? + + # ':' separator: HH:MM or HH:MM:SS + if zn.include?(':') + return parse_colon_offset(zn, sign) + end + + # '.' or ',' separator: HH.fraction + if zn.include?('.') || zn.include?(',') + return parse_fractional_offset(zn, sign) + end + + # Others: HH or HHMM or HHMMSS + parse_compact_offset(zn, sign) + end + + # str_end_with_word in date_parse.c. + # If the string 's' ends with "" (a word plus a space), + # Returns the length of that "" (including leading spaces). + # Otherwise, returns 0. + def str_end_with_word(s, word) + n = word.length + return 0 if s.length <= n + + # The last n characters match word (ignoring case) + return 0 unless s[-n..].casecmp?(word) + + # Is there a space just before it? + return 0 unless s[-(n + 1)].match?(/\s/) + + # Include consecutive spaces + count = n + 1 + count += 1 while count < s.length && s[-(count + 1)].match?(/\s/) + + count + end + + # shrink_space in date_parse.c. + # Combines consecutive spaces into a single space. + # If the length is the same as the original (normalization unnecessary), + # return it as is. + def shrink_space(s) + result = [] + prev_space = false + s.each_char do |ch| + if ch.match?(/\s/) + result << ' ' unless prev_space + prev_space = true + else + result << ch + prev_space = false + end + end + result.join + end + + # parse_colon_offset + # Parse "+HH:MM" or "+HH:MM:SS" and return the number of seconds. + # Range checking: hour 0-23, min 0-59, sec 0-59 + def parse_colon_offset(zn, sign) + parts = zn.split(':') + hour = parts[0].to_i + return nil if hour < 0 || hour > 23 + + min = parts.length > 1 ? parts[1].to_i : 0 + return nil if min < 0 || min > 59 + + sec = parts.length > 2 ? parts[2].to_i : 0 + return nil if sec < 0 || sec > 59 + + sign * (sec + min * 60 + hour * 3600) + end + + # Parse "+HH.fraction" or "+HH,fraction" and return the number of seconds. + # + # C logic: + # Read the fraction string up to 7 digits. + # sec = (read value) * 36 + # If n <= 2: + # If n == 1, sec *= 10 (treat HH.n as HH.n0) + # Return value = sec + hour * 3600 (Integer) + # If n > 2: + # Return value = Rational(sec, 10**(n-2)) + hour * 3600 + # Convert to an Integer if the denominator is 1. + # + # Reason for the 36 factor: + # 1 hour = 3600 seconds. Each decimal point is 1/10. Time = 360 seconds. + # However, since the implementation handles it in two-digit units, multiply + # by 36 before dividing by 10^2. + # (3600 / 100 = 36) + def parse_fractional_offset(zn, sign) + sep = zn.include?('.') ? '.' : ',' + hh_str, frac_str = zn.split(sep, 2) + hour = hh_str.to_i + return nil if hour < 0 || hour > 23 + + # Up to 7 digits (C: "no over precision for offset") + max_digits = 7 + frac_str = frac_str[0, max_digits] + n = frac_str.length + return sign * (hour * 3600) if n == 0 + + sec = frac_str.to_i * 36 # Convert to seconds by factor 36 + + if sign == -1 + hour = -hour + sec = -sec + end + + if n <= 2 + sec *= 10 if n == 1 # HH.n => HH.n0 + sec + hour * 3600 + else + # Rational for precise calculations + denom = 10 ** (n - 2) + offset = Rational(sec, denom) + (hour * 3600) + offset.denominator == 1 ? offset.to_i : offset + end + end + + # parse_compact_offset + # Parse consecutive numeric offsets without colons. + # HH (2 digits or less) + # HHM (3 digits: 1 digit for hour, 2 digits for min) + # HHMM (4 digits) + # HHMMM (5 digits: 2 digits for hour, 2 digits for min, 1 digit for sec) ... Rare in practical use + # HHMMSS (6 digits) + # + # C adjusts the leading padding width with "2 - l % 2". + # Ruby does the same calculation with length. + def parse_compact_offset(zn, sign) + l = zn.length + + # Only HH + return sign * zn.to_i * 3600 if l <= 2 + + # C: hour = scan_digits(&s[0], 2 - l % 2) + # min = scan_digits(&s[2 - l % 2], 2) + # sec = scan_digits(&s[4 - l % 2], 2) + # + # l=3 => hw=1 => hour=zn[0,1], min=zn[1,2] + # l=4 => hw=2 => hour=zn[0,2], min=zn[2,2] + # l=5 => hw=1 => hour=zn[0,1], min=zn[1,2], sec=zn[3,2] + # l=6 => hw=2 => hour=zn[0,2], min=zn[2,2], sec=zn[4,2] + hw = 2 - l % 2 # hour width: 2 for even, 1 for odd + hour = zn[0, hw].to_i + min = l >= 3 ? zn[hw, 2].to_i : 0 + sec = l >= 5 ? zn[hw + 2, 2].to_i : 0 + + sign * (sec + min * 60 + hour * 3600) + end + + # subx in date_parse.c. + # Matches pat against str. If it matches, replaces the matched + # portion of str (in-place) with rep (default: " ") and returns + # the MatchData. Returns nil on no match. + # + # This is the core mechanism C uses (via the SUBS macro) to + # prevent later parsers from re-matching already-consumed text. + def subx(str, pat, rep = " ") + m = pat.match(str) + return nil unless m + + str[m.begin(0), m.end(0) - m.begin(0)] = rep + m + end + + def check_class(str) + flags = 0 + str.each_char do |c| + flags |= HAVE_ALPHA if c =~ /[a-zA-Z]/ + flags |= HAVE_DIGIT if c =~ /\d/ + flags |= HAVE_DASH if c == '-' + flags |= HAVE_DOT if c == '.' + flags |= HAVE_SLASH if c == '/' + end + + flags + end + + # C macro HAVE_ELEM_P(x) in date_parse.c. + # Note: C calls check_class(str) every time because str is + # modified by subx. We do the same here. + def have_elem_p?(str, required) + (check_class(str) & required) == required + end + + # --- String type conversion (C's StringValue macro) --- + def string_value(str) + return str if str.is_a?(String) + if str.respond_to?(:to_str) + s = str.to_str + raise TypeError, "can't convert #{str.class} to String (#{str.class}#to_str gives #{s.class})" unless s.is_a?(String) + return s + end + raise TypeError, "no implicit conversion of #{str.class} into String" + end + + def check_string_limit(str, limit) + if limit && str.length > limit + raise ArgumentError, "string length (#{str.length}) exceeds the limit #{limit}" + end + end + + # C: d_new_by_frags + # Date-only fragment-based constructor. + # Time fields in hash are ignored — use dt_new_by_frags (in datetime.rb) for DateTime. + def new_by_frags(hash, sg) + raise Error, "invalid date" if hash.nil? || hash.empty? + + y = hash[:year] + m = hash[:mon] + d = hash[:mday] + + # Fast path: year+mon+mday present, no jd/yday + if !hash.key?(:jd) && !hash.key?(:yday) && y && m && d + raise Error, "invalid date" unless valid_civil?(y, m, d, sg) + obj = new(y, m, d, sg) + # Store parsed offset for deconstruct_keys([:zone]) without + # affecting JD calculations (don't use @of which triggers UTC conversion) + of = hash[:offset] + obj.instance_variable_set(:@parsed_offset, of) if of && of != 0 + return obj + end + + # Slow path — uses self (Date), so time-only patterns + # (e.g. '23:55') correctly fail: rt_complete_frags with Date class + # does not set :jd for :time pattern → rt__valid_date_frags_p returns nil. + hash = rt_rewrite_frags(hash) + hash = rt_complete_frags(self, hash) + jd = rt__valid_date_frags_p(hash, sg) + + raise Error, "invalid date" unless jd + + self.jd(jd, sg) + end + + # C: rt_rewrite_frags + # Converts :seconds (from %s/%Q) into jd/hour/min/sec/sec_fraction fields. + # + # C implementation (date_core.c:4033): + # seconds = del_hash("seconds"); + # if (!NIL_P(seconds)) { + # if (!NIL_P(offset)) seconds = f_add(seconds, offset); + # d = f_idiv(seconds, DAY_IN_SECONDS); + # fr = f_mod(seconds, DAY_IN_SECONDS); + # h = f_idiv(fr, HOUR_IN_SECONDS); fr = f_mod(fr, HOUR_IN_SECONDS); + # min= f_idiv(fr, MINUTE_IN_SECONDS); fr = f_mod(fr, MINUTE_IN_SECONDS); + # s = f_idiv(fr, 1); fr = f_mod(fr, 1); + # set jd = UNIX_EPOCH_IN_CJD + d, hour, min, sec, sec_fraction + # } + # + # Ruby's .div() and % match C's f_idiv (rb_intern("div")) and f_mod ('%'). + # Both use floor semantics, correctly handling negative and Rational values. + def rt_rewrite_frags(hash) + seconds = hash.delete(:seconds) + return hash unless seconds + + offset = hash[:offset] + seconds = seconds + offset if offset + + # Day count from Unix epoch + # C: d = f_idiv(seconds, DAY_IN_SECONDS) + d = seconds.div(DAY_IN_SECONDS) + fr = seconds % DAY_IN_SECONDS + + # Decompose remainder into h:min:s.frac + h = fr.div(HOUR_IN_SECONDS) + fr = fr % HOUR_IN_SECONDS + + min = fr.div(MINUTE_IN_SECONDS) + fr = fr % MINUTE_IN_SECONDS + + s = fr.div(1) + fr = fr % 1 + + # C: UNIX_EPOCH_IN_CJD = 2440588 (1970-01-01 in Chronological JD) + hash[:jd] = 2440588 + d + hash[:hour] = h + hash[:min] = min + hash[:sec] = s + hash[:sec_fraction] = fr + hash + end + + # C: rt_complete_frags (date_core.c:4071) + # + # Algorithm: + # 1. Score each of 11 field-set patterns against hash, pick highest match count. + # 2. For the winning named pattern, fill leading missing date fields from Date.today + # and set defaults for trailing date fields. + # 3. Special case: "time" pattern + DateTime class → set :jd from today. + # 4. Default :hour/:min/:sec to 0; clamp :sec to 59. + # + # Pattern table (C's static tab): + # [name, [fields...]] + # ────────────────────────── + # [:time, [:hour, :min, :sec]] + # [nil, [:jd]] + # [:ordinal, [:year, :yday, :hour, :min, :sec]] + # [:civil, [:year, :mon, :mday, :hour, :min, :sec]] + # [:commercial, [:cwyear, :cweek, :cwday, :hour, :min, :sec]] + # [:wday, [:wday, :hour, :min, :sec]] + # [:wnum0, [:year, :wnum0, :wday, :hour, :min, :sec]] + # [:wnum1, [:year, :wnum1, :wday, :hour, :min, :sec]] + # [nil, [:cwyear, :cweek, :wday, :hour, :min, :sec]] + # [nil, [:year, :wnum0, :cwday, :hour, :min, :sec]] + # [nil, [:year, :wnum1, :cwday, :hour, :min, :sec]] + # + def rt_complete_frags(klass, hash) + # Step 1: Find best matching pattern + # C: for each tab entry, count how many fields exist in hash; pick max. + # First match wins on tie (strict >). + best_key = nil + best_fields = nil + best_count = 0 + + COMPLETE_FRAGS_TABLE.each do |key, fields| + count = fields.count { |f| hash.key?(f) } + if count > best_count + best_count = count + best_key = key + best_fields = fields + end + end + + # Step 2: Complete missing fields for named patterns + # C: if (!NIL_P(k) && (RARRAY_LEN(a) > e)) + d = nil # lazy Date.today + + if best_key && best_fields && best_fields.length > best_count + case best_key + + when :ordinal + # C: fill year from today if missing, default yday=1 + unless hash.key?(:year) + d ||= today + hash[:year] = d.year + end + hash[:yday] ||= 1 + + when :civil + # C: fill leading missing fields from today, stop at first present field. + # Then default mon=1, mday=1. + # + # The loop iterates [:year, :mon, :mday, :hour, :min, :sec]. + # For each field, if it's already in hash → break. + # Otherwise fill from today via d.send(field). + # In practice, the loop only reaches date fields (:year/:mon/:mday) + # because at least one date field must be present for civil to win. + best_fields.each do |f| + break if hash.key?(f) + d ||= today + hash[f] = d.send(f) + end + hash[:mon] ||= 1 + hash[:mday] ||= 1 + + when :commercial + # C: same leading-fill pattern, then default cweek=1, cwday=1 + best_fields.each do |f| + break if hash.key?(f) + d ||= today + hash[f] = d.send(f) + end + hash[:cweek] ||= 1 + hash[:cwday] ||= 1 + + when :wday + # C: set_hash("jd", d_lite_jd(f_add(f_sub(d, d_lite_wday(d)), ref_hash("wday")))) + # → jd of (today - today.wday + parsed_wday) + d ||= today + hash[:jd] = (d - d.wday + hash[:wday]).jd + + when :wnum0 + # C: leading-fill from today, then default wnum0=0, wday=0 + best_fields.each do |f| + break if hash.key?(f) + d ||= today + # :year is the only field that can be missing before :wnum0 in practice + hash[f] = d.send(f) if d.respond_to?(f) + end + hash[:wnum0] ||= 0 + hash[:wday] ||= 0 + + when :wnum1 + # C: leading-fill from today, then default wnum1=0, wday=1 + best_fields.each do |f| + break if hash.key?(f) + d ||= today + hash[f] = d.send(f) if d.respond_to?(f) + end + hash[:wnum1] ||= 0 + hash[:wday] ||= 1 + end + end + + # Step 3: "time" pattern special case + # C: if (k == sym("time")) { if (f_le_p(klass, cDateTime)) { ... } } + # For DateTime (or subclass), time-only input gets :jd from today. + # For Date, time-only input will fail validation (no date fields). + if best_key == :time + if defined?(DateTime) && klass <= DateTime + d ||= today + hash[:jd] ||= d.jd + end + end + + # Step 4: Default time fields, clamp sec + # C: if (NIL_P(ref_hash("hour"))) set_hash("hour", 0); + # if (NIL_P(ref_hash("min"))) set_hash("min", 0); + # if (NIL_P(ref_hash("sec"))) set_hash("sec", 0); + # else if (ref_hash("sec") > 59) set_hash("sec", 59); + hash[:hour] ||= 0 + hash[:min] ||= 0 + if !hash.key?(:sec) + hash[:sec] = 0 + elsif hash[:sec] > 59 + hash[:sec] = 59 + end + + hash + end + + # C: rt__valid_date_frags_p (date_core.c:4379) + # Tries 6 strategies to produce a valid JD from hash fragments: + # jd → ordinal → civil → commercial → wnum0 → wnum1 + def rt__valid_date_frags_p(hash, sg) + # 1. Try jd (C: rt__valid_jd_p just returns jd) + if hash[:jd] + return hash[:jd] + end + + # 2. Try ordinal: year + yday + if hash[:yday] && hash[:year] + y = hash[:year] + yd = hash[:yday] + if valid_ordinal?(y, yd, sg) + return ordinal(y, yd, sg).jd + end + end + + # 3. Try civil: year + mon + mday + if hash[:mday] && hash[:mon] && hash[:year] + y = hash[:year] + m = hash[:mon] + d = hash[:mday] + if valid_civil?(y, m, d, sg) + return new(y, m, d, sg).jd + end + end + + # 4. Try commercial: cwyear + cweek + cwday/wday + # C: wday = ref_hash("cwday"); + # if (NIL_P(wday)) { wday = ref_hash("wday"); if wday==0 → wday=7; } + begin + wday = hash[:cwday] + if wday.nil? + wday = hash[:wday] + wday = 7 if wday && wday == 0 # Sunday: wday 0 → cwday 7 + end + + if wday && hash[:cweek] && hash[:cwyear] + jd = rt__valid_commercial_p(hash[:cwyear], hash[:cweek], wday, sg) + return jd if jd + end + end + + # 5. Try wnum0: year + wnum0 + wday (Sunday-first week, %U) + # C: wday = ref_hash("wday"); + # if (NIL_P(wday)) { wday = ref_hash("cwday"); if cwday==7 → wday=0; } + begin + wday = hash[:wday] + if wday.nil? + wday = hash[:cwday] + wday = 0 if wday && wday == 7 # Sunday: cwday 7 → wday 0 + end + + if wday && hash[:wnum0] && hash[:year] + jd = rt__valid_weeknum_p(hash[:year], hash[:wnum0], wday, 0, sg) + return jd if jd + end + end + + # 6. Try wnum1: year + wnum1 + wday (Monday-first week, %W) + # C: wday = ref_hash("wday"); if NIL → wday = ref_hash("cwday"); + # if wday → wday = (wday - 1) % 7 + begin + wday = hash[:wday] + wday = hash[:cwday] if wday.nil? + if wday + wday = (wday - 1) % 7 # Convert: 0(Sun)→6, 1(Mon)→0, ..., 7(Sun)→6 + end + + if wday && hash[:wnum1] && hash[:year] + jd = rt__valid_weeknum_p(hash[:year], hash[:wnum1], wday, 1, sg) + return jd if jd + end + end + + nil + end + + # C: rt__valid_commercial_p (date_core.c:4347) + # Validates commercial date and returns JD, or nil. + def rt__valid_commercial_p(y, w, d, sg) + if valid_commercial?(y, w, d, sg) + return commercial(y, w, d, sg).jd + end + nil + end + + # C: rt__valid_weeknum_p → valid_weeknum_p → c_valid_weeknum_p (date_core.c:1009) + # Validates weeknum-based date and returns JD, or nil. + # f=0 for Sunday-first (%U), f=1 for Monday-first (%W). + def rt__valid_weeknum_p(y, w, d, f, sg) + # C: if (d < 0) d += 7; + d += 7 if d < 0 + # C: if (w < 0) { ... normalize via next year ... } + if w < 0 + rjd2 = c_weeknum_to_jd(y + 1, 1, f, f, sg) + ry2, rw2, _ = c_jd_to_weeknum(rjd2 + w * 7, f, sg) + return nil if ry2 != y + w = rw2 + end + jd = c_weeknum_to_jd(y, w, d, f, sg) + ry, rw, rd = c_jd_to_weeknum(jd, f, sg) + return nil if y != ry || w != rw || d != rd + jd + end + + # C: c_weeknum_to_jd (date_core.c:663) + # Converts (year, week_number, day_in_week, first_day_flag, sg) → JD. + # + # C formula: + # c_find_fdoy(y, sg, &rjd2, &ns2); + # rjd2 += 6; + # *rjd = (rjd2 - MOD(((rjd2 - f) + 1), 7) - 7) + 7 * w + d; + def c_weeknum_to_jd(y, w, d, f, sg) + fdoy_jd, _ = c_find_fdoy(y, sg) + fdoy_jd += 6 + (fdoy_jd - ((fdoy_jd - f + 1) % 7) - 7) + 7 * w + d + end + + # C: c_jd_to_weeknum (date_core.c:674) + # Converts JD → [year, week_number, day_in_week]. + # Class-method version (the instance method in core.rb calls self.class.send). + # + # C formula: + # c_jd_to_civil(jd, sg, &ry, ...); + # c_find_fdoy(ry, sg, &rjd, ...); + # rjd += 6; + # j = jd - (rjd - MOD((rjd - f) + 1, 7)) + 7; + # rw = DIV(j, 7); + # rd = MOD(j, 7); + def c_jd_to_weeknum(jd, f, sg) + ry, _, _ = c_jd_to_civil(jd, sg) + fdoy_jd, _ = c_find_fdoy(ry, sg) + fdoy_jd += 6 + + j = jd - (fdoy_jd - ((fdoy_jd - f + 1) % 7)) + 7 + rw = j.div(7) + rd = j % 7 + + [ry, rw, rd] + end + + # --- comp_year helpers (C's comp_year69, comp_year50) --- + def comp_year69(y) + y >= 69 ? y + 1900 : y + 2000 + end + + def comp_year50(y) + y >= 50 ? y + 1900 : y + 2000 + end + + # --- sec_fraction helper --- + def sec_fraction(frac_str) + Rational(frac_str.to_i, 10 ** frac_str.length) + end + + # ================================================================ + # Format-specific parsers (date_parse.c) + # ================================================================ + + # --- ISO 8601 --- + + def date__iso8601(str) + hash = {} + return hash if str.nil? || str.empty? + + if (m = ISO8601_EXT_DATETIME_PAT.match(str)) + iso8601_ext_datetime_cb(m, hash) + elsif (m = ISO8601_BAS_DATETIME_PAT.match(str)) + iso8601_bas_datetime_cb(m, hash) + elsif (m = ISO8601_EXT_TIME_PAT.match(str)) + iso8601_time_cb(m, hash) + elsif (m = ISO8601_BAS_TIME_PAT.match(str)) + iso8601_time_cb(m, hash) + end + hash + end + + def iso8601_ext_datetime_cb(m, hash) + if m[1] + hash[:mday] = m[3].to_i if m[3] + if m[1] != '-' + y = m[1].to_i + y = comp_year69(y) if m[1].length < 4 + hash[:year] = y + end + if m[2].nil? + return false if m[1] != '-' + else + hash[:mon] = m[2].to_i + end + elsif m[5] + hash[:yday] = m[5].to_i + if m[4] + y = m[4].to_i + y = comp_year69(y) if m[4].length < 4 + hash[:year] = y + end + elsif m[8] + hash[:cweek] = m[7].to_i + hash[:cwday] = m[8].to_i + if m[6] + y = m[6].to_i + y = comp_year69(y) if m[6].length < 4 + hash[:cwyear] = y + end + elsif m[9] + hash[:cwday] = m[9].to_i + end + + if m[10] + hash[:hour] = m[10].to_i + hash[:min] = m[11].to_i + hash[:sec] = m[12].to_i if m[12] + end + hash[:sec_fraction] = sec_fraction(m[13]) if m[13] + if m[14] + hash[:zone] = m[14] + hash[:offset] = date_zone_to_diff(m[14]) + end + true + end + + def iso8601_bas_datetime_cb(m, hash) + if m[3] + hash[:mday] = m[3].to_i + if m[1] != '--' + y = m[1].to_i + y = comp_year69(y) if m[1].length < 4 + hash[:year] = y + end + if m[2][0] == '-' + return false if m[1] != '--' + else + hash[:mon] = m[2].to_i + end + elsif m[5] + hash[:yday] = m[5].to_i + y = m[4].to_i + y = comp_year69(y) if m[4].length < 4 + hash[:year] = y + elsif m[6] + hash[:yday] = m[6].to_i + elsif m[9] + hash[:cweek] = m[8].to_i + hash[:cwday] = m[9].to_i + y = m[7].to_i + y = comp_year69(y) if m[7].length < 4 + hash[:cwyear] = y + elsif m[11] + hash[:cweek] = m[10].to_i + hash[:cwday] = m[11].to_i + elsif m[12] + hash[:cwday] = m[12].to_i + end + + if m[13] + hash[:hour] = m[13].to_i + hash[:min] = m[14].to_i + hash[:sec] = m[15].to_i if m[15] + end + hash[:sec_fraction] = sec_fraction(m[16]) if m[16] + if m[17] + hash[:zone] = m[17] + hash[:offset] = date_zone_to_diff(m[17]) + end + true + end + + def iso8601_time_cb(m, hash) + hash[:hour] = m[1].to_i + hash[:min] = m[2].to_i + hash[:sec] = m[3].to_i if m[3] + hash[:sec_fraction] = sec_fraction(m[4]) if m[4] + if m[5] + hash[:zone] = m[5] + hash[:offset] = date_zone_to_diff(m[5]) + end + true + end + + # --- RFC 3339 --- + + def date__rfc3339(str) + hash = {} + return hash if str.nil? || str.empty? + + m = RFC3339_PAT.match(str) + return hash unless m + + hash[:year] = m[1].to_i + hash[:mon] = m[2].to_i + hash[:mday] = m[3].to_i + hash[:hour] = m[4].to_i + hash[:min] = m[5].to_i + hash[:sec] = m[6].to_i + hash[:zone] = m[8] + hash[:offset] = date_zone_to_diff(m[8]) + hash[:sec_fraction] = sec_fraction(m[7]) if m[7] + hash + end + + # --- XML Schema --- + + def date__xmlschema(str) + hash = {} + return hash if str.nil? || str.empty? + + if (m = XMLSCHEMA_DATETIME_PAT.match(str)) + hash[:year] = m[1].to_i + hash[:mon] = m[2].to_i if m[2] + hash[:mday] = m[3].to_i if m[3] + hash[:hour] = m[4].to_i if m[4] + hash[:min] = m[5].to_i if m[5] + hash[:sec] = m[6].to_i if m[6] + hash[:sec_fraction] = sec_fraction(m[7]) if m[7] + if m[8] + hash[:zone] = m[8] + hash[:offset] = date_zone_to_diff(m[8]) + end + elsif (m = XMLSCHEMA_TIME_PAT.match(str)) + hash[:hour] = m[1].to_i + hash[:min] = m[2].to_i + hash[:sec] = m[3].to_i if m[3] + hash[:sec_fraction] = sec_fraction(m[4]) if m[4] + if m[5] + hash[:zone] = m[5] + hash[:offset] = date_zone_to_diff(m[5]) + end + elsif (m = XMLSCHEMA_TRUNC_PAT.match(str)) + hash[:mon] = m[1].to_i if m[1] + hash[:mday] = m[2].to_i if m[2] + hash[:mday] = m[3].to_i if m[3] + if m[4] + hash[:zone] = m[4] + hash[:offset] = date_zone_to_diff(m[4]) + end + end + hash + end + + # --- RFC 2822 --- + + def date__rfc2822(str) + hash = {} + return hash if str.nil? || str.empty? + + m = PARSE_RFC2822_PAT.match(str) + return hash unless m + + hash[:wday] = day_num(m[1]) if m[1] + hash[:mday] = m[2].to_i + hash[:mon] = mon_num(m[3]) + y = m[4].to_i + y = comp_year50(y) if m[4].length < 4 + hash[:year] = y + hash[:hour] = m[5].to_i + hash[:min] = m[6].to_i + hash[:sec] = m[7].to_i if m[7] + hash[:zone] = m[8] + hash[:offset] = date_zone_to_diff(m[8]) + hash + end + + # --- HTTP date --- + + def date__httpdate(str) + hash = {} + return hash if str.nil? || str.empty? + + if (m = PARSE_HTTPDATE_TYPE1_PAT.match(str)) + hash[:wday] = day_num(m[1]) + hash[:mday] = m[2].to_i + hash[:mon] = mon_num(m[3]) + hash[:year] = m[4].to_i + hash[:hour] = m[5].to_i + hash[:min] = m[6].to_i + hash[:sec] = m[7].to_i + hash[:zone] = m[8] + hash[:offset] = 0 + elsif (m = PARSE_HTTPDATE_TYPE2_PAT.match(str)) + hash[:wday] = day_num(m[1]) + hash[:mday] = m[2].to_i + hash[:mon] = mon_num(m[3]) + y = m[4].to_i + y = comp_year69(y) if y >= 0 && y <= 99 + hash[:year] = y + hash[:hour] = m[5].to_i + hash[:min] = m[6].to_i + hash[:sec] = m[7].to_i + hash[:zone] = m[8] + hash[:offset] = 0 + elsif (m = PARSE_HTTPDATE_TYPE3_PAT.match(str)) + hash[:wday] = day_num(m[1]) + hash[:mon] = mon_num(m[2]) + hash[:mday] = m[3].to_i + hash[:hour] = m[4].to_i + hash[:min] = m[5].to_i + hash[:sec] = m[6].to_i + hash[:year] = m[7].to_i + end + hash + end + + # --- JIS X 0301 --- + + def date__jisx0301(str) + hash = {} + return hash if str.nil? || str.empty? + + m = PARSE_JISX0301_PAT.match(str) + if m + era = m[1] || JISX0301_DEFAULT_ERA + ep = gengo(era) + hash[:year] = ep + m[2].to_i + hash[:mon] = m[3].to_i + hash[:mday] = m[4].to_i + if m[5] + hash[:hour] = m[5].to_i + hash[:min] = m[6].to_i if m[6] + hash[:sec] = m[7].to_i if m[7] + end + hash[:sec_fraction] = sec_fraction(m[8]) if m[8] && !m[8].empty? + if m[9] + hash[:zone] = m[9] + hash[:offset] = date_zone_to_diff(m[9]) + end + else + # Fallback to iso8601 + hash = date__iso8601(str) + end + hash + end + end +end diff --git a/lib/date/patterns.rb b/lib/date/patterns.rb new file mode 100644 index 0000000..b8437b2 --- /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 = STRFTIME_DEFAULT_FMT) + # If format is not a string, convert it to a string. + format = format.to_str unless format.is_a?(String) + + # Check for ASCII compatible encoding. + raise ArgumentError, "format should have ASCII compatible encoding" unless format.encoding.ascii_compatible? + + # Empty format returns empty string + return '' if format.empty? + + # What to do if format string contains a "\0". + if format.include?("\0") + result = String.new + parts = format.split("\0", -1) + + parts.each_with_index do |part, i| + result << strftime_format(part) unless part.empty? + result << "\0" if i < parts.length - 1 + end + + result.force_encoding(format.encoding) + + return result + end + + # Normal processing without "\0" in format string. + result = strftime_format(format) + result.force_encoding(format.encoding) + + result + end + + private + + def tmx_year + m_real_year + end + + def tmx_mon + mon + end + + def tmx_mday + mday + end + + def tmx_yday + yday + end + + def tmx_cwyear + m_real_cwyear + end + + def tmx_cweek + cweek + end + + def tmx_cwday + cwday + end + + def tmx_wday + wday + end + + def tmx_wnum0 + # Week number (Sunday start, 00-53) + m_wnumx(0) + end + + def tmx_wnum1 + # Week number (Monday start, 00-53) + m_wnumx(1) + end + + def tmx_hour + if simple_dat_p? + 0 + else + df = df_utc_to_local(m_df, m_of) + (df / 3600).floor + end + end + + def tmx_min + if simple_dat_p? + 0 + else + df = df_utc_to_local(m_df, m_of) + ((df % 3600) / 60).floor + end + end + + def tmx_sec + if simple_dat_p? + 0 + else + df = df_utc_to_local(m_df, m_of) + df % 60 + end + end + + def tmx_sec_fraction + if simple_dat_p? + Rational(0, 1) + else + # (Decimal part of df) + sf + df_frac = m_df - m_df.floor + sf_frac = m_sf == 0 ? 0 : Rational(m_sf, SECOND_IN_NANOSECONDS) + df_frac + sf_frac + end + end + + def tmx_secs + # C: tmx_m_secs (date_core.c:7306) + # s = day_to_sec(m_real_jd - UNIX_EPOCH_IN_CJD) + # if complex: s += m_df + s = jd_to_unix_time(m_real_jd) + return s if simple_dat_p? + df = m_df + s += df if df != 0 + s + end + + def tmx_msecs + # C: tmx_m_msecs (date_core.c:7322) + # s = tmx_m_secs * 1000 + # if complex: s += m_sf / MILLISECOND_IN_NANOSECONDS + s = tmx_secs * SECOND_IN_MILLISECONDS + return s if simple_dat_p? + sf = m_sf + s += (sf / (SECOND_IN_NANOSECONDS / SECOND_IN_MILLISECONDS)).to_i if sf != 0 + s + end + + def tmx_offset + simple_dat_p? ? 0 : m_of + end + + def tmx_zone + if simple_dat_p? || tmx_offset.zero? + "+00:00" + else + of2str(m_of) + end + end + + def of2str(of) + s, h, m = decode_offset(of) + sprintf('%c%02d:%02d', s, h, m) + end + + def decode_offset(of) + s = (of < 0) ? '-' : '+' + a = of.abs + h = a / HOUR_IN_SECONDS + m = (a % HOUR_IN_SECONDS) / MINUTE_IN_SECONDS + [s, h, m] + end + + # Processing format strings. + def strftime_format(format) + result = String.new + i = 0 + + while i < format.length + if format[i] == '%' && i + 1 < format.length + # Skip '%' + i += 1 + + # C: Parse all modifiers in a flat loop (flags, width, colons, E/O) + flags = String.new + width = String.new + modifier = nil + colons = 0 + + while i < format.length + c = format[i] + case c + when 'E', 'O' + modifier = c + i += 1 + when ':' + colons += 1 + i += 1 + when '-', '_', '^', '#' + flags << c + i += 1 + when '0' + # '0' is a flag only when width is still empty + if width.empty? + flags << c + i += 1 + else + width << c + i += 1 + end + when /[1-9]/ + width << c + i += 1 + # Continue reading remaining digits + while i < format.length && format[i] =~ /[0-9]/ + width << format[i] + i += 1 + end + else + break + end + end + + # Invalid if both E/O and colon modifiers are present. + if modifier && colons > 0 + if i < format.length + spec = format[i] + result << "%#{modifier}#{':' * colons}#{spec}" + i += 1 + end + next + end + + # Width specifier overflow check + unless width.empty? + if width.length > 10 || (width.length == 10 && width > '2147483647') + raise Errno::ERANGE, "Result too large" + end + if width.to_i >= 1024 + raise Errno::ERANGE, "Result too large" + end + end + + if i < format.length + spec = format[i] + + if modifier + # E/O modifier check must come first + valid = case modifier + when 'E' + %w[c C x X y Y].include?(spec) + when 'O' + %w[d e H k I l m M S u U V w W y].include?(spec) + else + false + end + + if valid + formatted = format_spec(spec, flags, width) + result << formatted + else + result << "%#{modifier}#{flags}#{width}#{spec}" + end + elsif spec == 'z' + # %z with any combination of colons/width/flags + formatted = format_z(tmx_offset, width, flags, colons) + result << formatted + elsif colons > 0 + # Colon modifier is only valid for 'z'. + result << "%#{':' * colons}#{flags}#{width}#{spec}" + else + formatted = format_spec(spec, flags, width) + result << formatted + end + + i += 1 + end + else + result << format[i] + i += 1 + end + end + + result.force_encoding('US-ASCII') if result.ascii_only? + + result + end + + def format_spec(spec, flags = '', width = '') + # N/L: width controls precision (number of fractional digits) + if spec == 'N' || spec == 'L' + precision = if !width.empty? + width.to_i + elsif spec == 'L' + 3 + else + 9 + end + frac = tmx_sec_fraction + digits = (frac * (10 ** precision)).floor + return sprintf("%0#{precision}d", digits) + end + + # Get basic formatting results. + base_result = get_base_format(spec, flags) + + # Apply case change flags (before width/precision) + base_result = apply_case_flags(base_result, spec, flags) + + # Apply width specifier. + if !width.empty? + width_num = width.to_i + default_pad = if NUMERIC_SPECS.include?(spec) + '0' + elsif SPACE_PAD_SPECS.include?(spec) + ' ' + else + ' ' + end + apply_width(base_result, width_num, flags, default_pad) + else + base_result + end + end + + # C: Apply ^ (UPPER) and # (CHCASE) flags + def apply_case_flags(str, spec, flags) + if flags.include?('^') + str.upcase + elsif flags.include?('#') + if CHCASE_UPPER_SPECS.include?(spec) + str.upcase + elsif CHCASE_LOWER_SPECS.include?(spec) + str.downcase + else + str.swapcase + end + else + str + end + end + + # format specifiers + def get_base_format(spec, flags = '') + case spec + when 'Y' # 4-digit year + y = tmx_year + raise Errno::ERANGE, "Result too large" if y.is_a?(Integer) && y.bit_length > 128 + # C: FMT('0', y >= 0 ? 4 : 5, "ld", y) + prec = y < 0 ? 5 : 4 + if flags.include?('-') + y.to_s + elsif flags.include?('_') + sprintf("%#{prec}d", y) + else + sprintf("%0#{prec}d", y) + end + when 'C' # Century + sprintf('%02d', tmx_year / 100) + when 'y' # Two-digit year + sprintf('%02d', tmx_year % 100) + when 'm' # Month (01-12) + sprintf('%02d', tmx_mon) + when 'B' # Full month name + MONTHNAMES[tmx_mon] || '?' + when 'b', 'h' # Abbreviated month name + (ABBR_MONTHNAMES[tmx_mon] || '?')[0, 3] + when 'd' # Day (01-31) + if flags.include?('-') + # Left-justified (no padding) + tmx_mday.to_s + elsif flags.include?('_') + # Space-padded + sprintf('%2d', tmx_mday) + else + # Zero-padded (default) + sprintf('%02d', tmx_mday) + end + when 'e' # Day (1-31) blank filled + if flags.include?('-') + tmx_mday.to_s + elsif flags.include?('0') + sprintf('%02d', tmx_mday) + else + sprintf('%2d', tmx_mday) + end + when 'j' # Day of the year (001-366) + if flags.include?('-') + tmx_yday.to_s + else + sprintf('%03d', tmx_yday) + end + when 'H' # Hour (00-23) + if flags.include?('-') + tmx_hour.to_s + elsif flags.include?('_') + sprintf('%2d', tmx_hour) + else + sprintf('%02d', tmx_hour) + end + when 'k' # Hour (0-23) blank-padded + sprintf('%2d', tmx_hour) + when 'I' # Hour (01-12) + h = tmx_hour % 12 + h = 12 if h.zero? + if flags.include?('-') + h.to_s + elsif flags.include?('_') + sprintf('%2d', h) + else + sprintf('%02d', h) + end + when 'l' # Hour (1-12) blank filled + h = tmx_hour % 12 + h = 12 if h.zero? + sprintf('%2d', h) + when 'M' # Minutes (00-59) + if flags.include?('-') + tmx_min.to_s + elsif flags.include?('_') + sprintf('%2d', tmx_min) + else + sprintf('%02d', tmx_min) + end + when 'S' # Seconds (00-59) + if flags.include?('-') + tmx_sec.to_s + elsif flags.include?('_') + sprintf('%2d', tmx_sec) + else + sprintf('%02d', tmx_sec) + end + when 'L' # Milliseconds (000-999) + sprintf('%09d', (tmx_sec_fraction * 1_000_000_000).floor) + when 'N' # Fractional seconds digits + # C: width controls precision (number of digits), default 9. + # %3N → 3 digits (milliseconds), %6N → 6 digits (microseconds), + # %9N → 9 digits (nanoseconds), %12N → 12 digits (picoseconds, zero-padded). + # The 'width' variable is handled specially in format_spec for 'N'. + sprintf('%09d', (tmx_sec_fraction * 1_000_000_000).floor) + when 'P' # am/pm + tmx_hour < 12 ? 'am' : 'pm' + when 'p' # AM/PM + tmx_hour < 12 ? 'AM' : 'PM' + when 'A' # Full name of the day of the week + DAYNAMES[tmx_wday] || '?' + when 'a' # Abbreviated day of the week + (ABBR_DAYNAMES[tmx_wday] || '?')[0, 3] + when 'w' # Day of the week (0-6, Sunday is 0) + tmx_wday.to_s + when 'u' # Day of the week (1-7, Monday is 1) + tmx_cwday.to_s + when 'U' # Week number (00-53, Sunday start) + sprintf('%02d', tmx_wnum0) + when 'W' # Week number (00-53, Monday start) + sprintf('%02d', tmx_wnum1) + when 'V' # ISO week number (01-53) + sprintf('%02d', tmx_cweek) + when 'G' # ISO week year + y = tmx_cwyear + prec = y < 0 ? 5 : 4 + if flags.include?('-') + y.to_s + elsif flags.include?('_') + sprintf("%#{prec}d", y) + else + sprintf("%0#{prec}d", y) + end + when 'g' # ISO week year (2 digits) + sprintf('%02d', tmx_cwyear % 100) + when 'z' # Time Zone Offset (+0900) — handled by format_z in format_spec + format_z(tmx_offset, '', '', 0) + when 'Z' # Time Zone Name + tmx_zone || '' + when 's' # Number of seconds since the Unix epoch + tmx_secs.to_s + when 'Q' # Milliseconds since the Unix epoch + tmx_msecs.to_s + when 'n' # Line breaks + "\n" + when 't' # Tab + "\t" + when '%' # % symbol + '%' + when 'F' # %Y-%m-%d + strftime_format('%Y-%m-%d') + when 'D' # %m/%d/%y + strftime_format('%m/%d/%y') + when 'x' # %m/%d/%y + strftime_format('%m/%d/%y') + when 'T', 'X' # %H:%M:%S + strftime_format('%H:%M:%S') + when 'R' # %H:%M + strftime_format('%H:%M') + when 'r' # %I:%M:%S %p + strftime_format('%I:%M:%S %p') + when 'c' # %a %b %e %H:%M:%S %Y + strftime_format('%a %b %e %H:%M:%S %Y') + when 'v' # %e-%^b-%Y (3-FEB-2001 format) + day_str = sprintf('%2d', tmx_mday) + month_str = (ABBR_MONTHNAMES[tmx_mon] || '?')[0, 3].upcase + year_str = sprintf('%04d', tmx_year) + "#{day_str}-#{month_str}-#{year_str}" + when '+' # %a %b %e %H:%M:%S %Z %Y + strftime_format('%a %b %e %H:%M:%S %Z %Y') + else + # Unknown specifiers are output as is. + "%#{spec}" + end + end + + def apply_width(str, width, flags, default_pad = ' ') + # '-' flag means no padding at all + return str if flags.include?('-') + return str if str.length >= width + + # Determine a padding character. + padding = + if flags.include?('0') + '0' + elsif flags.include?('_') + ' ' + else + default_pad + end + + str.rjust(width, padding) + end + + # C: format %z with width/flags/colons support + # Matches date_strftime.c case 'z' logic exactly. + def format_z(offset, width_str, flags, colons) + sign = offset < 0 ? '-' : '+' + aoff = offset.abs + hours = aoff / 3600 + minutes = (aoff % 3600) / 60 + seconds = aoff % 60 + + hl = hours < 10 ? 1 : 2 # actual digits needed for hours + hw = 2 # default hour width + hw = 1 if flags.include?('-') && hl == 1 + + precision = width_str.empty? ? -1 : width_str.to_i + + # Calculate fixed chars (everything except hour digits) per colons variant + fixed = case colons + when 0 then 3 # sign(1) + mm(2) + when 1 then 4 # sign(1) + :(1) + mm(2) + when 2 then 7 # sign(1) + :(1) + mm(2) + :(1) + ss(2) + when 3 + if (aoff % 3600).zero? + 1 # sign(1) only + elsif (aoff % 60).zero? + 4 # sign(1) + :(1) + mm(2) + else + 7 # sign(1) + :(1) + mm(2) + :(1) + ss(2) + end + else + 3 + end + + # C: hour_precision = precision <= (fixed + hw) ? hw : precision - fixed + hp = precision <= (fixed + hw) ? hw : precision - fixed + + result = String.new + + # C: space padding — print spaces before sign, reduce hour precision + if flags.include?('_') && hp > hl + result << ' ' * (hp - hl) + hp = hl + end + + result << sign + result << sprintf("%0#{hp}d", hours) + + # Append minutes/seconds based on colons + case colons + when 0 + result << sprintf('%02d', minutes) + when 1 + result << sprintf(':%02d', minutes) + when 2 + result << sprintf(':%02d:%02d', minutes, seconds) + when 3 + unless (aoff % 3600).zero? + result << sprintf(':%02d', minutes) + unless (aoff % 60).zero? + result << sprintf(':%02d', seconds) + end + end + end + + result + end + + def jd_to_unix_time(jd) + unix_epoch_jd = 2440588 + (jd - unix_epoch_jd) * DAY_IN_SECONDS + end +end diff --git a/lib/date/strptime.rb b/lib/date/strptime.rb new file mode 100644 index 0000000..e73bfc5 --- /dev/null +++ b/lib/date/strptime.rb @@ -0,0 +1,769 @@ +# frozen_string_literal: true + +# Implementation of ruby/date/ext/date/date_strptime.c +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') + str = string.dup + pos = 0 + hash = {} + + i = 0 + while i < format.length + if format[i] == '%' && i + 1 < format.length + i += 1 + + # Parse modifier (E, O) + modifier = nil + if i < format.length && (format[i] == 'E' || format[i] == 'O') + modifier = format[i] + i += 1 + end + + # Parse colons for %:z, %::z, %:::z + colons = 0 + while i < format.length && format[i] == ':' + colons += 1 + i += 1 + end + + # Parse width + width_str = String.new + while i < format.length && format[i] =~ /[0-9]/ + width_str << format[i] + i += 1 + end + + break if i >= format.length + + spec = format[i] + i += 1 + + # Handle E/O modifier validity + if modifier + valid = case modifier + when 'E' + %w[c C x X y Y].include?(spec) + when 'O' + %w[d e H I m M S u U V w W y].include?(spec) + else + false + end + unless valid + # Invalid modifier - try to match literal + literal = "%#{modifier}#{':' * colons}#{width_str}#{spec}" + if str[pos, literal.length] == literal + pos += literal.length + else + return nil + end + next + end + end + + # Handle colon+z + if colons > 0 && spec == 'z' + result = _strptime_zone_colon(str, pos, colons) + return nil unless result + pos = result[:pos] + hash[:zone] = result[:zone] + hash[:offset] = result[:offset] + next + elsif colons > 0 + # Invalid colon usage + return nil + end + + # Determine field width + field_width = width_str.empty? ? nil : width_str.to_i + + # C: NUM_PATTERN_P() - check if next format element is a digit-consuming pattern. + # Used by %C, %G, %L, %N, %Y to limit digit consumption when adjacent. + next_is_num = num_pattern_p(format, i) + + result = _strptime_spec(str, pos, spec, field_width, hash, next_is_num) + return nil unless result + pos = result[:pos] + hash.merge!(result[:hash]) if result[:hash] + elsif format[i] == '%' && i + 1 == format.length + # Trailing % - match literal + if pos < str.length && str[pos] == '%' + pos += 1 + else + return nil + end + i += 1 + elsif format[i] =~ /\s/ + # Whitespace in format matches zero or more whitespace in input + i += 1 + pos += 1 while pos < str.length && str[pos] =~ /\s/ + else + # Literal match + if pos < str.length && str[pos] == format[i] + pos += 1 + else + return nil + end + i += 1 + end + end + + # Store leftover if any + if pos < str.length + hash[:leftover] = str[pos..] + end + + # --- Post-processing (C: date__strptime, date_strptime.c:524-546) --- + + # C: cent = del_hash("_cent"); + # Apply _cent to both cwyear and year. + # Note: The inline _century approach in %C/%y/%g handlers covers most + # cases, but this post-processing ensures correctness for all orderings + # and applies century to both year and cwyear simultaneously. + # We delete _century and _century_set here to keep the hash clean, + # matching C's del_hash("_cent") behavior. + hash.delete(:_century) + hash.delete(:_century_set) + + # C: merid = del_hash("_merid"); + # Apply _merid to hour: hour = (hour % 12) + merid + # This handles both %I (12-hour) and %H (24-hour) correctly: + # %I=12 + AM(0) → (12 % 12) + 0 = 0 + # %I=12 + PM(12) → (12 % 12) + 12 = 12 + # %I=4 + PM(12) → (4 % 12) + 12 = 16 + # %I=4 + AM(0) → (4 % 12) + 0 = 4 + merid = hash.delete(:_merid) + if merid + hour = hash[:hour] + if hour + hash[:hour] = (hour % 12) + merid + end + 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) + hash = _strptime(string, format) + raise Error, "invalid strptime format - `#{format}'" unless hash + + # Apply comp for 2-digit year + if hash[:year] && !hash[:_century_set] + # If year came from %y (2-digit), comp_year69 was already applied + end + + new_by_frags(hash, start) + end + + private + + # C: num_pattern_p (date_strptime.c:48) + # Returns true if the format string at position `i` starts with a + # digit-consuming pattern (a literal digit or a %-specifier that reads digits). + def num_pattern_p(format, i) + return false if i >= format.length + c = format[i] + return true if c =~ /\d/ + if c == '%' + i += 1 + return false if i >= format.length + # Skip E/O modifier + if format[i] == 'E' || format[i] == 'O' + i += 1 + return false if i >= format.length + end + s = format[i] + return true if s =~ /\d/ || NUM_PATTERN_SPECS.include?(s) + end + false + end + + def _strptime_spec(str, pos, spec, width, context_hash, next_is_num = false) + h = {} + + case spec + when 'Y' # Full year (possibly negative) + # C: if (NUM_PATTERN_P()) READ_DIGITS(n, 4); else READ_DIGITS_MAX(n); + if width + w = width + elsif next_is_num + w = 4 + else + w = 40 # effectively unlimited + end + m = str[pos..].match(/\A([+-]?\d{1,#{w}})/) + return nil unless m + h[:year] = m[1].to_i + { pos: pos + m[0].length, hash: h } + + when 'C' # Century + # C: if (NUM_PATTERN_P()) READ_DIGITS(n, 2); else READ_DIGITS_MAX(n); + if width + w = width + elsif next_is_num + w = 2 + else + w = 40 + end + m = str[pos..].match(/\A([+-]?\d{1,#{w}})/) + return nil unless m + century = m[1].to_i + h[:_century] = century + if context_hash[:year] && !context_hash[:_century_set] + h[:year] = century * 100 + (context_hash[:year] % 100) + h[:_century_set] = true + end + { pos: pos + m[0].length, hash: h } + + when 'y' # 2-digit year + w = width || 2 + m = str[pos..].match(/\A(\d{1,#{w}})/) + return nil unless m + y = m[1].to_i + if context_hash[:_century] + h[:year] = context_hash[:_century] * 100 + y + h[:_century_set] = true + else + h[:year] = y >= 69 ? y + 1900 : y + 2000 + end + { pos: pos + m[0].length, hash: h } + + when 'm' # Month (01-12) + w = width || 2 + m = str[pos..].match(/\A(\d{1,#{w}})/) + return nil unless m + mon = m[1].to_i + return nil if mon < 1 || mon > 12 + h[:mon] = mon + { pos: pos + m[0].length, hash: h } + + when 'd', 'e' # Day of month + # C: if (str[si] == ' ') { si++; READ_DIGITS(n, 1); } else { READ_DIGITS(n, 2); } + if str[pos] == ' ' + m = str[pos + 1..].match(/\A(\d)/) + return nil unless m + day = m[1].to_i + return nil if day < 1 || day > 31 + h[:mday] = day + { pos: pos + 1 + m[0].length, hash: h } + else + w = width || 2 + m = str[pos..].match(/\A(\d{1,#{w}})/) + return nil unless m + day = m[1].to_i + return nil if day < 1 || day > 31 + h[:mday] = day + { pos: pos + m[0].length, hash: h } + end + + when 'j' # Day of year (001-366) + w = width || 3 + m = str[pos..].match(/\A(\d{1,#{w}})/) + return nil unless m + yday = m[1].to_i + return nil if yday < 1 || yday > 366 + h[:yday] = yday + { pos: pos + m[0].length, hash: h } + + when 'H', 'k' # Hour (00-24) + # C: if (str[si] == ' ') { si++; READ_DIGITS(n, 1); } else { READ_DIGITS(n, 2); } + if str[pos] == ' ' + m = str[pos + 1..].match(/\A(\d)/) + return nil unless m + hour = m[1].to_i + return nil if hour > 24 + h[:hour] = hour + { pos: pos + 1 + m[0].length, hash: h } + else + w = width || 2 + m = str[pos..].match(/\A(\d{1,#{w}})/) + return nil unless m + hour = m[1].to_i + return nil if hour > 24 + h[:hour] = hour + { pos: pos + m[0].length, hash: h } + end + + when 'I', 'l' # Hour (01-12) + # C: if (str[si] == ' ') { si++; READ_DIGITS(n, 1); } else { READ_DIGITS(n, 2); } + if str[pos] == ' ' + m = str[pos + 1..].match(/\A(\d)/) + return nil unless m + hour = m[1].to_i + return nil if hour < 1 || hour > 12 + h[:hour] = hour + { pos: pos + 1 + m[0].length, hash: h } + else + w = width || 2 + m = str[pos..].match(/\A(\d{1,#{w}})/) + return nil unless m + hour = m[1].to_i + return nil if hour < 1 || hour > 12 + h[:hour] = hour # C stores raw value; _merid post-processing applies % 12 + { pos: pos + m[0].length, hash: h } + end + + when 'M' # Minute (00-59) + w = width || 2 + m = str[pos..].match(/\A(\d{1,#{w}})/) + return nil unless m + min = m[1].to_i + return nil if min > 59 + h[:min] = min + { pos: pos + m[0].length, hash: h } + + when 'S' # Second (00-60) + w = width || 2 + m = str[pos..].match(/\A(\d{1,#{w}})/) + return nil unless m + sec = m[1].to_i + return nil if sec > 60 + h[:sec] = sec + { pos: pos + m[0].length, hash: h } + + when 'L' # Milliseconds + # C: if (NUM_PATTERN_P()) READ_DIGITS(n, 3); else READ_DIGITS_MAX(n); + if width + w = width + elsif next_is_num + w = 3 + else + w = 40 + end + m = str[pos..].match(/\A(\d{1,#{w}})/) + return nil unless m + frac_str = m[1].ljust(3, '0')[0, 3] + h[:sec_fraction] = Rational(frac_str.to_i, 1000) + { pos: pos + m[0].length, hash: h } + + when 'N' # Nanoseconds + # C: if (NUM_PATTERN_P()) READ_DIGITS(n, 9); else READ_DIGITS_MAX(n); + if width + w = width + elsif next_is_num + w = 9 + else + w = 40 + end + m = str[pos..].match(/\A(\d{1,#{w}})/) + return nil unless m + frac_str = m[1].ljust(9, '0')[0, 9] + h[:sec_fraction] = Rational(frac_str.to_i, 1_000_000_000) + { pos: pos + m[0].length, hash: h } + + when 'p', 'P' # AM/PM + # C: set_hash("_merid", INT2FIX(hour)); + # Store _merid value (0 for AM, 12 for PM) for post-processing. + # This avoids order-dependency: %p can appear before or after %I/%H. + m = str[pos..].match(/\A(a\.?m\.?|p\.?m\.?)/i) + return nil unless m + ampm = m[1].delete('.').upcase + h[:_merid] = (ampm == 'PM') ? 12 : 0 + { pos: pos + m[0].length, hash: h } + + when 'A', 'a' # Day name (full or abbreviated) + DAYNAMES.each_with_index do |name, idx| + next unless name + # Try full name first, then abbreviated + [name, ABBR_DAYNAMES[idx]].each do |n| + next unless n + if str[pos, n.length]&.downcase == n.downcase + h[:wday] = idx + return { pos: pos + n.length, hash: h } + end + end + end + return nil + + when 'B', 'b', 'h' # Month name (full or abbreviated) + MONTHNAMES.each_with_index do |name, idx| + next unless name + # Try full name first, then abbreviated + [name, ABBR_MONTHNAMES[idx]].each do |n| + next unless n + if str[pos, n.length]&.downcase == n.downcase + h[:mon] = idx + return { pos: pos + n.length, hash: h } + end + end + end + return nil + + when 'w' # Weekday number (0-6, Sunday=0) + m = str[pos..].match(/\A(\d)/) + return nil unless m + wday = m[1].to_i + return nil if wday > 6 + h[:wday] = wday + { pos: pos + m[0].length, hash: h } + + when 'u' # Weekday number (1-7, Monday=1) + m = str[pos..].match(/\A(\d)/) + return nil unless m + cwday = m[1].to_i + return nil if cwday < 1 || cwday > 7 + h[:cwday] = cwday + { pos: pos + m[0].length, hash: h } + + when 'U' # Week number (Sunday start, 00-53) + w = width || 2 + m = str[pos..].match(/\A(\d{1,#{w}})/) + return nil unless m + wnum = m[1].to_i + return nil if wnum > 53 + h[:wnum0] = wnum + { pos: pos + m[0].length, hash: h } + + when 'W' # Week number (Monday start, 00-53) + w = width || 2 + m = str[pos..].match(/\A(\d{1,#{w}})/) + return nil unless m + wnum = m[1].to_i + return nil if wnum > 53 + h[:wnum1] = wnum + { pos: pos + m[0].length, hash: h } + + when 'V' # ISO week number (01-53) + w = width || 2 + m = str[pos..].match(/\A(\d{1,#{w}})/) + return nil unless m + cweek = m[1].to_i + return nil if cweek < 1 || cweek > 53 + h[:cweek] = cweek + { pos: pos + m[0].length, hash: h } + + when 'G' # ISO week year + # C: if (NUM_PATTERN_P()) READ_DIGITS(n, 4); else READ_DIGITS_MAX(n); + if width + w = width + elsif next_is_num + w = 4 + else + w = 40 + end + m = str[pos..].match(/\A([+-]?\d{1,#{w}})/) + return nil unless m + h[:cwyear] = m[1].to_i + { pos: pos + m[0].length, hash: h } + + when 'g' # ISO week year (2-digit) + w = width || 2 + m = str[pos..].match(/\A(\d{1,#{w}})/) + return nil unless m + y = m[1].to_i + if context_hash[:_century] + h[:cwyear] = context_hash[:_century] * 100 + y + h[:_century_set] = true + else + h[:cwyear] = y >= 69 ? y + 1900 : y + 2000 + end + { pos: pos + m[0].length, hash: h } + + when 'Z', 'z' # Timezone + result = _strptime_zone(str, pos) + return nil unless result + h[:zone] = result[:zone] + h[:offset] = result[:offset] unless result[:offset].nil? + { pos: result[:pos], hash: h } + + when 's' # Seconds since epoch + m = str[pos..].match(/\A([+-]?\d+)/) + return nil unless m + h[:seconds] = m[1].to_i + { pos: pos + m[0].length, hash: h } + + when 'Q' # Milliseconds since epoch + m = str[pos..].match(/\A([+-]?\d+)/) + return nil unless m + h[:seconds] = Rational(m[1].to_i, 1000) + { pos: pos + m[0].length, hash: h } + + when 'n' # Newline + m = str[pos..].match(/\A\s+/) + if m + { pos: pos + m[0].length, hash: h } + else + { pos: pos, hash: h } + end + + when 't' # Tab + m = str[pos..].match(/\A\s+/) + if m + { pos: pos + m[0].length, hash: h } + else + { pos: pos, hash: h } + end + + when '%' # Literal % + if pos < str.length && str[pos] == '%' + { pos: pos + 1, hash: h } + else + return nil + end + + when 'F' # %Y-%m-%d + result = _strptime_composite(str, pos, '%Y-%m-%d', context_hash) + return nil unless result + { pos: result[:pos], hash: result[:hash] } + + when 'D', 'x' # %m/%d/%y + result = _strptime_composite(str, pos, '%m/%d/%y', context_hash) + return nil unless result + { pos: result[:pos], hash: result[:hash] } + + when 'T', 'X' # %H:%M:%S + result = _strptime_composite(str, pos, '%H:%M:%S', context_hash) + return nil unless result + { pos: result[:pos], hash: result[:hash] } + + when 'R' # %H:%M + result = _strptime_composite(str, pos, '%H:%M', context_hash) + return nil unless result + { pos: result[:pos], hash: result[:hash] } + + when 'r' # %I:%M:%S %p + result = _strptime_composite(str, pos, '%I:%M:%S %p', context_hash) + return nil unless result + { pos: result[:pos], hash: result[:hash] } + + when 'c' # %a %b %e %H:%M:%S %Y + result = _strptime_composite(str, pos, '%a %b %e %H:%M:%S %Y', context_hash) + return nil unless result + { pos: result[:pos], hash: result[:hash] } + + when 'v' # %e-%b-%Y + result = _strptime_composite(str, pos, '%e-%b-%Y', context_hash) + return nil unless result + { pos: result[:pos], hash: result[:hash] } + + when '+' # %a %b %e %H:%M:%S %Z %Y + result = _strptime_composite(str, pos, '%a %b %e %H:%M:%S %Z %Y', context_hash) + return nil unless result + { pos: result[:pos], hash: result[:hash] } + + else + # Unknown specifier - try to match literal + literal = "%#{spec}" + if str[pos, literal.length] == literal + { pos: pos + literal.length, hash: h } + else + return nil + end + end + end + + def _strptime_composite(str, pos, format, context_hash) + merged_hash = context_hash.dup + i = 0 + while i < format.length + if format[i] == '%' && i + 1 < format.length + i += 1 + spec = format[i] + i += 1 + result = _strptime_spec(str, pos, spec, nil, merged_hash) + return nil unless result + pos = result[:pos] + merged_hash.merge!(result[:hash]) if result[:hash] + elsif format[i] =~ /\s/ + i += 1 + pos += 1 while pos < str.length && str[pos] =~ /\s/ + else + if pos < str.length && str[pos] == format[i] + pos += 1 + else + return nil + end + i += 1 + end + end + # Return only newly parsed keys + new_hash = {} + merged_hash.each { |k, v| new_hash[k] = v unless context_hash.key?(k) && context_hash[k] == v } + # Ensure updated values are included + merged_hash.each { |k, v| new_hash[k] = v if context_hash[k] != v } + { pos: pos, hash: new_hash } + end + + def _strptime_zone(str, pos) + remaining = str[pos..] + return nil if remaining.nil? || remaining.empty? + + # Try numeric timezone: +HH:MM, -HH:MM, +HH:MM:SS, -HH:MM:SS, +HHMM, -HHMM, +HH, -HH + # Also: GMT+HH, GMT-HH:MM, etc. and decimal offsets + # Colon-separated pattern (requires colon) tried first, then plain digits. + m = remaining.match(/\A( + (?:GMT|UTC)? + [+-] + (?:\d{1,2}:\d{2}(?::\d{2})? + | + \d+(?:[.,]\d+)?) + )/xi) + + if m + zone_str = m[1] + offset = _parse_zone_offset(zone_str) + return { pos: pos + zone_str.length, zone: zone_str, offset: offset } + end + + # Try named timezone (multi-word: "E. Australia Standard Time", "Mountain Daylight Time") + # Match alphabetic words with dots and spaces + m = remaining.match(/\A([A-Za-z][A-Za-z.]*(?:\s+[A-Za-z][A-Za-z.]*)*)/i) + if m + zone_candidate = m[1] + # Try progressively shorter matches (longest first) + words = zone_candidate.split(/\s+/) + (words.length).downto(1) do |n| + try_zone = words[0, n].join(' ') + offset = _zone_name_to_offset(try_zone) + if offset + # Compute actual consumed length preserving original spacing + if n == words.length + actual_zone = zone_candidate + else + # Find end of nth word in original string + end_pos = 0 + n.times do |wi| + end_pos = zone_candidate.index(words[wi], end_pos) + end_pos += words[wi].length + end + actual_zone = zone_candidate[0, end_pos] + end + return { pos: pos + actual_zone.length, zone: actual_zone, offset: offset } + end + end + # Unknown timezone - return full match with nil offset + # (Military single-letter zones like 'z' are already in ZONE_TABLE + # and handled by the loop above) + return { pos: pos + zone_candidate.length, zone: zone_candidate, offset: nil } + end + + nil + end + + def _strptime_zone_colon(str, pos, colons) + remaining = str[pos..] + return nil if remaining.nil? || remaining.empty? + + case colons + when 1 # %:z -> +HH:MM + m = remaining.match(/\A([+-])(\d{2}):(\d{2})/) + return nil unless m + sign = m[1] == '-' ? -1 : 1 + offset = sign * (m[2].to_i * 3600 + m[3].to_i * 60) + zone = m[0] + { pos: pos + zone.length, zone: zone, offset: offset } + when 2 # %::z -> +HH:MM:SS + m = remaining.match(/\A([+-])(\d{2}):(\d{2}):(\d{2})/) + return nil unless m + sign = m[1] == '-' ? -1 : 1 + offset = sign * (m[2].to_i * 3600 + m[3].to_i * 60 + m[4].to_i) + zone = m[0] + { pos: pos + zone.length, zone: zone, offset: offset } + when 3 # %:::z -> +HH[:MM[:SS]] + m = remaining.match(/\A([+-])(\d{2})(?::(\d{2})(?::(\d{2}))?)?/) + return nil unless m + sign = m[1] == '-' ? -1 : 1 + offset = sign * (m[2].to_i * 3600 + (m[3] ? m[3].to_i * 60 : 0) + (m[4] ? m[4].to_i : 0)) + zone = m[0] + { pos: pos + zone.length, zone: zone, offset: offset } + else + nil + end + end + + def _parse_zone_offset(zone_str) + # Strip GMT/UTC prefix + s = zone_str.sub(/\A(?:GMT|UTC)/i, '') + return 0 if s.empty? + + m = s.match(/\A([+-])(\d+(?:[.,]\d+)?)$/) + if m + sign = m[1] == '-' ? -1 : 1 + num = m[2].tr(',', '.') + if num.include?('.') + # Decimal hours + hours = num.to_f + return nil if hours.abs >= 24 + return sign * (hours * 3600).to_i + else + # Could be HH, HHMM, or HHMMSS + digits = num + case digits.length + when 1, 2 + h = digits.to_i + return nil if h >= 24 + return sign * h * 3600 + when 3, 4 + h = digits[0, 2].to_i + min = digits[2, 2].to_i + return nil if h >= 24 || min >= 60 + return sign * (h * 3600 + min * 60) + when 5, 6 + h = digits[0, 2].to_i + min = digits[2, 2].to_i + sec = digits[4, 2].to_i + return nil if h >= 24 || min >= 60 || sec >= 60 + return sign * (h * 3600 + min * 60 + sec) + else + return nil + end + end + end + + # +HH:MM or +HH:MM:SS + m = s.match(/\A([+-])(\d{1,2}):(\d{2})(?::(\d{2}))?$/) + if m + sign = m[1] == '-' ? -1 : 1 + h = m[2].to_i + min = m[3].to_i + sec = m[4] ? m[4].to_i : 0 + return nil if h >= 24 || min >= 60 || sec >= 60 + return sign * (h * 3600 + min * 60 + sec) + end + + nil + end + + def _zone_name_to_offset(name) + ZONE_TABLE[name.downcase.gsub(/\s+/, ' ')] + end + end +end diff --git a/lib/date/time.rb b/lib/date/time.rb new file mode 100644 index 0000000..3b73236 --- /dev/null +++ b/lib/date/time.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +class Time + def to_time + self + end unless method_defined?(:to_time) + + def to_date + y = year + m = month + d = day + + nth, ry = Date.send(:decode_year, y, -1) + + # First, create it in GREGORIAN (dates during the reform period are also valid). + obj = Date.send(:d_simple_new_internal, + nth, 0, + Date::GREGORIAN, + ry, m, d, + 0x04) # Date::HAVE_CIVIL + + # Then change to DEFAULT_SG. + obj.send(:set_sg, Date::ITALY) + + obj + end unless method_defined?(:to_date) + + def to_datetime + y = year + m = month + d = day + h = hour + mi = min + s = sec + of_sec = utc_offset + sf = nsec + + nth, ry = Date.send(:decode_year, y, -1) + rjd, _ = Date.send(:c_civil_to_jd, ry, m, d, Date::GREGORIAN) + + df = h * 3600 + mi * 60 + s + + # Convert local to UTC + df_utc = df - of_sec + jd_utc = rjd + if df_utc < 0 + jd_utc -= 1 + df_utc += 86400 + elsif df_utc >= 86400 + jd_utc += 1 + df_utc -= 86400 + end + + obj = DateTime.send(:new_with_jd_and_time, nth, jd_utc, df_utc, sf, of_sec, Date::GREGORIAN) + obj.send(:set_sg, Date::ITALY) + + obj + end unless method_defined?(:to_datetime) +end diff --git a/lib/date/version.rb b/lib/date/version.rb new file mode 100644 index 0000000..063fe24 --- /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 0000000..c26262f --- /dev/null +++ b/lib/date/zonetab.rb @@ -0,0 +1,405 @@ +# frozen_string_literal: true + +# Timezone name => UTC offset (seconds) mapping table +# Converted C's zonetab.h (gperf-generated hash table) to a Ruby hash. +# 316 zones in total. +# Search ignores case (.downcase on the caller before searching). +# +# Original data source: zonetab.h #included in date_parse.c +# gperf --ignore-case -L ANSI-C -C -c -P -p -j1 -i 1 -g -o -t \ +# -N zonetab zonetab.list +# The complete static hash table generated by was converted equivalently to a Ruby hash. +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, + # Multi-word timezone names from C's zonetab.list + "met dst" => 7200, + "mountain standard time" => -25200, + "mountain daylight time" => -21600, + "pacific standard time" => -28800, + "pacific daylight time" => -25200, + "eastern standard time" => -18000, + "eastern daylight time" => -14400, + "central standard time" => -21600, + "central daylight time" => -18000, + "atlantic standard time" => -14400, + "atlantic daylight time" => -10800, + "e. australia standard time" => 36000, + "cen. australia standard time" => 34200, + "w. australia standard time" => 28800, + "dateline standard time" => -43200, + "fiji standard time" => 43200, + "samoa standard time" => -39600, + "new zealand standard time" => 43200, + "taipei standard time" => 28800, + "tokyo standard time" => 32400, + "china standard time" => 28800, + "india standard time" => 19800, + "korea standard time" => 32400, + "singapore standard time" => 28800, + "north asia standard time" => 25200, + "north asia east standard time" => 28800, + "se asia standard time" => 25200, + "west asia standard time" => 18000, + "romance standard time" => 3600, + "russian standard time" => 10800, + "us eastern standard time" => -18000, + "us mountain standard time" => -25200, + "sa western standard time" => -14400, + "sa pacific standard time" => -18000, + "sa eastern standard time" => -10800, + "e. south america standard time" => -10800, + "greenland standard time" => -10800, + "mid-atlantic standard time" => -7200, + "azores standard time" => -3600, + "cape verde standard time" => -3600, + "gmt standard time" => 0, + "greenwich standard time" => 0, + "w. europe standard time" => 3600, + "central europe standard time" => 3600, + "central european standard time" => 3600, + "gtb standard time" => 7200, + "e. europe standard time" => 7200, + "egypt standard time" => 7200, + "south africa standard time" => 7200, + "fle standard time" => 7200, + "israel standard time" => 7200, + "arabian standard time" => 14400, + "arab standard time" => 10800, + "e. africa standard time" => 10800, + "iran standard time" => 12600, + "west pacific standard time" => 36000, + "aus central standard time" => 34200, + "aus eastern standard time" => 36000, + "central pacific standard time" => 39600, + "tasmania standard time" => 36000, + "canada central standard time" => -21600, + "mexico standard time" => -21600, + "mexico standard time 2" => -25200, + "central america standard time" => -21600, + "nepal standard time" => 20700, + "sri lanka standard time" => 21600, + "n. central asia standard time" => 21600, + "central asia standard time" => 21600, + "afghanistan standard time" => 16200, + "ekaterinburg standard time" => 18000, + "alaska standard time" => -32400, + "alaska daylight time" => -28800, + "hawaii standard time" => -36000, + }.freeze +end From 0cdfd42220808c6759e3c24d48c23c2a612641be Mon Sep 17 00:00:00 2001 From: jinroq Date: Sun, 15 Feb 2026 16:02:25 +0900 Subject: [PATCH 2/3] Put `# encoding: US-ASCII` at the beginning. --- lib/date/constants.rb | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/lib/date/constants.rb b/lib/date/constants.rb index d9db674..11bdb96 100644 --- a/lib/date/constants.rb +++ b/lib/date/constants.rb @@ -1,3 +1,4 @@ +# encoding: US-ASCII # frozen_string_literal: true # Constants @@ -10,15 +11,11 @@ class Date private_constant :HAVE_JD, :HAVE_DF, :HAVE_CIVIL, :HAVE_TIME, :COMPLEX_DAT MONTHNAMES = [nil, "January", "February", "March", "April", "May", "June", - "July", "August", "September", "October", "November", "December"] - .map { |s| s&.encode(Encoding::US_ASCII)&.freeze }.freeze + "July", "August", "September", "October", "November", "December"].freeze ABBR_MONTHNAMES = [nil, "Jan", "Feb", "Mar", "Apr", "May", "Jun", - "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] - .map { |s| s&.encode(Encoding::US_ASCII)&.freeze }.freeze - DAYNAMES = %w[Sunday Monday Tuesday Wednesday Thursday Friday Saturday] - .map { |s| s.encode(Encoding::US_ASCII).freeze }.freeze - ABBR_DAYNAMES = %w[Sun Mon Tue Wed Thu Fri Sat] - .map { |s| s.encode(Encoding::US_ASCII).freeze }.freeze + "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 # Pattern constants for regex ABBR_DAYS_PATTERN = 'sun|mon|tue|wed|thu|fri|sat' @@ -149,7 +146,7 @@ class Date private_constant :HAVE_ALPHA, :HAVE_DIGIT, :HAVE_DASH, :HAVE_DOT, :HAVE_SLASH # C: default strftime format is US-ASCII - STRFTIME_DEFAULT_FMT = '%F'.encode(Encoding::US_ASCII) + STRFTIME_DEFAULT_FMT = '%F' private_constant :STRFTIME_DEFAULT_FMT # strftime spec categories From 802e0c2e1b3a259332016191f8de87d45fcd9f64 Mon Sep 17 00:00:00 2001 From: jinroq Date: Mon, 16 Feb 2026 00:36:49 +0900 Subject: [PATCH 3/3] Optimized methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit | Implementation | i/s | μs/i | | :--- | :--- | :--- | | System (C ext) | 347.5k | 2.88 | | Pre-optimization (pure Ruby) | 313.5k | 3.19 | | Post-optimization (pure Ruby) | 380.0k | 2.63 | | Implementation | i/s | μs/i | | :--- | :--- | :--- | | System (C ext) | 4.32M | 0.23 | | Pre-optimization (pure Ruby) | 312k | 3.20 | | Post-optimization (pure Ruby) | 1.67M | 0.60 | **5.4x speedup** (312k → 1.67M i/s). Reached approximately **39%** of the C extension's performance. | Implementation | i/s | | :--- | :--- | | System (C ext) | 4.50M | | Pre-optimization (pure Ruby) | 311k | | Post-optimization (pure Ruby) | 1.63M | For cases where the fast path is not applicable (e.g., Julian calendar or BCE years), performance remains equivalent to the previous implementation (no changes). The fast path is applied when all of the following conditions are met: 1. `year`, `month`, and `day` are all `Integer`. 2. The date is determined to be strictly Gregorian (e.g., `start` is `GREGORIAN`, or a reform date like `ITALY` with `year > 1930`). By satisfying these conditions, the implementation skips six `self.class.send` calls, `Hash` allocations, redundant `decode_year` calls, and repetitive array generation. | Implementation | i/s | | :--- | :--- | | System (C ext) | 9.58M | | Pre-optimization (pure Ruby) | 458k | | Post-optimization (pure Ruby) | 2.51M | **5.5x speedup** (458k → 2.51M i/s). Reached approximately **26%** of the C extension's performance. | Implementation | i/s | | :--- | :--- | | System (C ext) | 9.59M | | Pre-optimization (pure Ruby) | 574k | | Post-optimization (pure Ruby) | 2.53M | **4.4x speedup.** 1. **Added a Fast Path** — For `Integer` arguments and Gregorian calendar cases, the entire method chain of `numeric?` (called 3 times) and `valid_civil_sub` is skipped. Instead, month and day range checks are performed inline. 2. **Eliminated Repeated Array Allocation in `valid_civil_sub`** — Changed the implementation to reference a `MONTH_DAYS` constant instead of creating a new array `[nil, 31, 28, ...]` on every call. | Case | System (C ext) | Pre-optimization | Post-optimization | | :--- | :--- | :--- | :--- | | Date.jd | 4.12M | 462k | 1.18M | | Date.jd(0) | 4.20M | 467k | 1.19M | | Date.jd(JULIAN) | 4.09M | 468k | 1.22M | | Date.jd(GREG) | 4.07M | 467k | 1.21M | **Approximately 2.6x speedup** (462k → 1.18M i/s). Reached approximately **29%** of the C extension's performance. The fast path is effective across all `start` patterns (`ITALY` / `JULIAN` / `GREGORIAN`). The following processes are now skipped: - `valid_sg` + `c_valid_start_p` (numerous type checks) - `value_trunc` (array allocation for `Integer`) - `decode_jd` (array allocation for standard Julian Days) - `d_simple_new_internal` (`canon` + flag operations + method call overhead) | Case | System (C ext) | Pre-optimization | Post-optimization | Improvement | | :--- | :--- | :--- | :--- | :--- | | Date.ordinal | 2.66M | 170k | 645k | 3.8x | | Date.ordinal(-1) | 1.87M | 119k | 639k | 5.4x | | Date.ordinal(neg) | 3.08M | 107k | 106k | (Slow path) | **3.8x to 5.4x speedup** in cases where the fast path is applicable. Reached approximately **24% to 34%** of the C extension's performance. `Date.ordinal(neg)` remains on the slow path (equivalent to previous performance) because the year -4712 does not meet the fast path condition (`year > REFORM_END_YEAR`). | Case | System (C ext) | Pre-optimization | Post-optimization | Improvement | | :--- | :--- | :--- | :--- | :--- | | Date.commercial | 2.18M | 126k | 574k | 4.5x | | Date.commercial(-1) | 1.45M | 85k | 560k | 6.6x | | Date.commercial(neg) | 2.84M | 93k | 90k | (Slow path) | **4.5x to 6.6x speedup** in cases where the fast path is applicable. Reached approximately **26% to 39%** of the C extension's performance. Inlined the ISO week-to-JD conversion: 1. Obtain the JD for Jan 1 using `c_gregorian_civil_to_jd(year, 1, 1)` (requires only one method call). 2. Directly calculate `max_weeks` (52 or 53) from the ISO weekday to perform a week range check. 3. Calculate the Monday of Week 1 using: `base = (jd_jan1 + 3) - ((jd_jan1 + 3) % 7)`. 4. Directly calculate the JD using: `rjd = base + 7*(week-1) + (day-1)`. This bypasses the entire previous chain of `valid_commercial_p` → `c_valid_commercial_p` → `c_commercial_to_jd` → `c_jd_to_commercial` (verification via inverse conversion). | Case | System (C ext) | Pre-optimization | Post-optimization | Improvement | | :--- | :--- | :--- | :--- | :--- | | valid_ordinal? (true) | 3.76M | 221k | 3.38M | 15.3x | | valid_ordinal? (false) | 3.77M | 250k | 3.39M | 13.6x | | valid_ordinal? (-1) | 2.37M | 148k | 2.67M | 18.0x | **15x to 18x speedup.** Performance reached **90% to 112%** of the C extension, making it nearly equivalent or even slightly faster. Since `valid_ordinal?` does not require object instantiation and only involves leap year determination and day-of-year range checks, the inline cost of the fast path is extremely low, allowing it to rival the performance of the C extension. | Case | System (C ext) | Pre-optimization | Post-optimization | Improvement | | :--- | :--- | :--- | :--- | :--- | | valid_commercial? (true) | 2.94M | 167k | 1.09M | 6.5x | | valid_commercial? (false) | 3.56M | 218k | 1.08M | 5.0x | | valid_commercial? (-1) | 1.79M | 104k | 1.07M | 10.3x | **5x to 10x speedup.** Performance reached approximately **30% to 37%** of the C extension. The same ISO week validation logic used in the `Date.commercial` fast path (calculating `max_weeks` from the JD of Jan 1 and performing `cwday`/`cweek` range checks) has been inlined. The reason it does not rival the C extension as closely as `valid_ordinal?` is due to the remaining overhead of a single method call to `c_gregorian_civil_to_jd(year, 1, 1)`. | Method | i/s | | :--- | :--- | | Date.valid_jd? | 9.29M | | Date.valid_jd?(false) | 9.68M | It is approximately **3.3x faster** compared to the C extension benchmarks (Reference values: 2.93M / 2.80M). The simplification to only perform type checks has had a significant impact on performance. | Method | Pre-optimization | Post-optimization | Improvement | | :--- | :--- | :--- | :--- | | Date.gregorian_leap?(2000) | 1.40M | 7.39M | 5.3x | | Date.gregorian_leap?(1900) | 1.39M | 7.48M | 5.4x | It is approximately **4.5x faster** even when compared to the C extension reference values (1.69M / 1.66M). For `Integer` arguments, the implementation now performs the leap year determination inline, skipping three method calls: the `numeric?` check, `decode_year`, and `c_gregorian_leap_p?`. Non-`Integer` arguments (such as `Rational`) will fall back to the conventional path. | Method | Pre-optimization | Post-optimization | Improvement | | :--- | :--- | :--- | :--- | | Date.julian_leap? | 2.27M | 8.98M | 4.0x | It is approximately **3.2x faster** even when compared to the C extension reference value (2.80M). For `Integer` arguments, the implementation now skips calls to `numeric?`, `decode_year`, and `c_julian_leap_p?`, returning the result directly via an inline `year % 4 == 0` check. | Method | Pre-optimization | Post-optimization | Improvement | | :--- | :--- | :--- | :--- | | Date#year | 3.27M | 10.06M | 3.1x | It is approximately **2.8x faster** even when compared to the C extension reference value (3.65M). In cases where `@nth == 0 && @has_civil` (which covers almost all typical use cases), the implementation now skips the `m_year` → `simple_dat_p?` → `get_s_civil` method chain as well as `self.class.send(:f_zero_p?, nth)`, returning `@year` directly. Add early return in `m_mon` when `@has_civil` is already true, skipping `simple_dat_p?` check and `get_s_civil`/`get_c_civil` method call overhead. Same pattern as `m_real_year`. Benchmark results (Ruby 4.0.1, benchmark-ips): Date#month: C 21,314,867 ips -> Ruby 14,302,144 ips (67.1%) DateTime#month: C 20,843,168 ips -> Ruby 14,113,170 ips (67.7%) Add early return in `m_mday` when `@has_civil` is already true, skipping `simple_dat_p?` check and `get_s_civil`/`get_c_civil` method call overhead. Same pattern as `m_real_year` and `m_mon`. Benchmark results (Ruby 4.0.1, benchmark-ips): Date#day: C 18,415,779 ips -> Ruby 14,248,797 ips (77.4%) DateTime#day: C 18,758,870 ips -> Ruby 13,750,236 ips (73.3%) Add early return in `m_wday` when `@has_jd` is true and `@of` is nil (simple Date), inlining `(@jd + 1) % 7` directly. This skips `m_local_jd`, `get_s_jd`, `c_jd_to_wday` method call overhead. Benchmark results (Ruby 4.0.1, benchmark-ips): Date#wday: C 20,923,653 ips -> Ruby 11,174,133 ips (53.4%) DateTime#wday: C 20,234,376 ips -> Ruby 3,721,404 ips (18.4%) Note: DateTime#wday is not covered by this fast path since it requires offset-aware local JD calculation. Add fast path in `m_yday` for simple Date (`@of.nil?`) with `@has_civil` already computed. When the calendar is proleptic Gregorian or the date is well past the reform period, compute yday directly via `YEARTAB[month] + day`, skipping `m_local_jd`, `m_virtual_sg`, `m_year`, `m_mon`, `m_mday`, and other method call overhead. Benchmark results (Ruby 4.0.1, benchmark-ips): Date#yday: C 16,253,269 ips -> Ruby 1,942,757 ips (12.0%) DateTime#yday: C 14,927,308 ips -> Ruby 851,319 ips ( 5.7%) Note: DateTime#yday is not covered by this fast path since it requires offset-aware local JD calculation. Multiple optimizations to `Date#+` and its object creation path: 1. Eliminate `instance_variable_set` in `new_with_jd_and_time`: Replace 10 `instance_variable_set` calls with a protected `_init_with_jd` method using direct `@var =` assignment. Benefits all callers (Date#+, Date#-, Date#>>, DateTime#+, etc). 2. Avoid `self.class.send` overhead in `Date#+`: Replace `self.class.send(:new_with_jd, ...)` chain with direct `self.class.allocate` + `obj._init_with_jd(...)` (protected call). 3. Eager JD computation in `Date.civil` fast path: Compute JD via Neri-Schneider algorithm in `initialize` instead of deferring. Ensures `@has_jd = true` from creation, so `Date#+` always takes the fast `@has_jd` path. 4. Add `_init_simple_with_jd` with only 4 ivar assignments: For simple Date fast path, skip 7 nil assignments that `allocate` already provides as undefined (returns nil). 5. Fix fast path condition to handle `@has_civil` without `@has_jd`: When only civil data is available, compute JD inline via Neri-Schneider before addition. Benchmark results (Ruby 4.0.1, benchmark-ips): Date#+1: C 5,961,579 ips -> Ruby 3,150,254 ips (52.8%) Date#+100: C 6,054,311 ips -> Ruby 3,088,684 ips (51.0%) Date#-1: C 4,077,013 ips -> Ruby 2,488,817 ips (61.0%) Date#+1 progression: Before: 1,065,416 ips (17.9% of C) After ivar_set removal: 1,972,000 ips (33.1% of C) After send avoidance: 2,691,799 ips (45.2% of C) After eager JD + 4-ivar init: 3,150,254 ips (52.8% of C) Date#-1: C 4,077,013 ips -> Ruby 2,863,047 ips (70.2%) Date#-1 progression: Before: 989,991 ips (24.3% of C) After Date#+ optimization: 2,488,817 ips (61.0% of C) After Date#- fast path: 2,863,047 ips (70.2% of C) Date#<<1: C 2,214,936 ips -> Ruby 1,632,773 ips (73.7%) Date#<<1 progression: Before: 205,555 ips ( 9.3% of C) After Date#>> optimization: 1,574,551 ips (71.1% of C) After direct fast path: 1,632,773 ips (73.7% of C) - Ruby version: 4.0 (Docker) - C baseline: bench/results/20260215/4.0.1_system.tsv - Tool: benchmark-ips ┌──────────────┬─────────┬────────────┬─────────┐ │ Benchmark │ C (ips) │ Ruby (ips) │ Ruby/C │ ├──────────────┼─────────┼────────────┼─────────┤ │ Date#<<1 │ 2.21 M │ 1.62 M │ 1/1.4x │ ├──────────────┼─────────┼────────────┼─────────┤ │ DateTime#<<1 │ 2.13 M │ 177.53 K │ 1/12.0x │ └──────────────┴─────────┴────────────┴─────────┘ Changes: Replaced the slow path of Date#<< which delegated to self >> (-n) with an inlined version of Date#>>'s slow path logic. This eliminates the extra method call, sign negation, and redundant condition checks. - Date#<< (Date only): reaches 71% of C performance - DateTime#<< (with offset): remains at 1/12x due to the slow path being exercised more heavily - Ruby version: 4.0 (Docker) - C baseline: bench/results/20260215/4.0.1_system.tsv - Tool: benchmark-ips ┌──────────────┬─────────┬───────────────────┬──────────────────┬─────────┐ │ Benchmark │ C (ips) │ Ruby before (ips) │ Ruby after (ips) │ after/C │ ├──────────────┼─────────┼───────────────────┼──────────────────┼─────────┤ │ Date#<=> │ 11.84 M │ 635.23 K │ 2.99 M │ 1/4.0x │ ├──────────────┼─────────┼───────────────────┼──────────────────┼─────────┤ │ DateTime#<=> │ 12.24 M │ 622.88 K │ 577.00 K │ 1/21.2x │ └──────────────┴─────────┴───────────────────┴──────────────────┴─────────┘ Changes: Added a fast path to `Date#<=>` for the common case where both objects are simple Date instances (`@df`, `@sf`, `@of` are all `nil`) with `@nth == 0` and `@has_jd` set. In this case, the comparison reduces to a direct `@jd <=> other.@jd` integer comparison, eliminating two `m_canonicalize_jd` calls (each of which allocates a `[nth, jd]` array via `canonicalize_jd`), redundant `simple_dat_p?` checks, and chained accessor calls for `m_nth`, `m_jd`, `m_df`, and `m_sf`. - `Date#<=>` (Date only): 4.7x improvement over pre-optimization Ruby, reaches 75% of C performance - `DateTime#<=>` (with offset): unaffected — falls through to the existing slow path Benchmark: Date#== optimization (pure Ruby vs C) - Ruby version: 4.0 (Docker) - C baseline: bench/results/20260215/4.0.1_system.tsv - Tool: benchmark-ips ┌─────────────┬─────────┬───────────────────┬──────────────────┬─────────┐ │ Benchmark │ C (ips) │ Ruby before (ips) │ Ruby after (ips) │ after/C │ ├─────────────┼─────────┼───────────────────┼──────────────────┼─────────┤ │ Date#== │ 2.78 M │ 875.47 K │ 3.24 M │ 1.17x │ ├─────────────┼─────────┼───────────────────┼──────────────────┼─────────┤ │ DateTime#== │ 2.72 M │ 798.68 K │ 924.96 K │ 1/2.9x │ └─────────────┴─────────┴───────────────────┴──────────────────┴─────────┘ Changes: Added a fast path to `Date#==` for the common case where both objects are simple Date instances (`@df`, `@sf`, `@of` are all `nil`) with `@nth == 0` and `@has_jd` set. In this case, equality reduces to a direct `@jd == other.@jd` integer comparison. This eliminates two `m_canonicalize_jd` calls (each allocating a `[nth, jd]` array via `canonicalize_jd`), redundant `simple_dat_p?` checks, and chained accessor calls for `m_nth`, `m_jd`, `m_df`, and `m_sf`. - `Date#==` (Date only): 3.7x improvement over pre-optimization Ruby, 17% faster than C - `DateTime#==` (with offset): unaffected — falls through to the existing slow path Add fast paths that skip `m_canonicalize_jd` (which allocates an array) for the common case: both objects are simple (`@df`, `@sf`, `@of` are all `nil`), `@nth == 0`, `@has_jd` is true, and `0 <= @jd < CM_PERIOD` (guaranteeing that canonicalization is a no-op). For `Date#===`, whether the two dates are on the same calendar or not, the result always reduces to `@jd == other.@jd` under these conditions, so the `m_gregorian_p?` check and both `m_canonicalize_jd` calls are eliminated. For `Date#hash`, the same bounds guarantee that `m_nth == 0` and `m_jd == @jd` after canonicalization, so `[0, @jd, @sg].hash` is returned directly. | Method | Before | After | Speedup | C impl | |-------------|-------------|--------------|---------|--------------| | `Date#===` | ~558K ips | ~2,940K ips | +5.3x | ~12,659K ips | | `Date#hash` | ~1,990K ips | ~6,873K ips | +3.5x | ~13,833K ips | feat: Optimized `Date#<`. Add an explicit `Date#<` method with a fast path that bypasses the `Comparable` module overhead. When both objects are simple (`@df`, `@sf`, `@of` are all `nil`), `@nth == 0`, and `@has_jd` is true, `@jd < other.@jd` is returned directly without going through `<=>`. The slow path delegates to `super` (Comparable) to preserve all edge-case behavior including `ArgumentError` for incomparable types. | Method | Before | After | Speedup | C impl | |----------|-------------|-------------|---------|-------------| | `Date#<` | ~2,430K ips | ~3,330K ips | +37% | ~7,628K ips | Add an explicit `Date#>` method with a fast path that bypasses the `Comparable` module overhead. When both objects are simple (`@df`, `@sf`, `@of` are all `nil`), `@nth == 0`, and `@has_jd` is true, `@jd > other.@jd` is returned directly without going through `<=>`. The slow path delegates to `super` (Comparable) to preserve all edge-case behavior including `ArgumentError` for incomparable types. | Method | Before | After | Speedup | C impl | |----------|-------------|-------------|---------|-------------| | `Date#>` | ~2,560K ips | ~3,330K ips | +30% | ~7,682K ips | --- lib/date/constants.rb | 8 +- lib/date/core.rb | 540 +++++++++++++++++++++++++++++++++++++----- 2 files changed, 481 insertions(+), 67 deletions(-) diff --git a/lib/date/constants.rb b/lib/date/constants.rb index 11bdb96..2f7ca2b 100644 --- a/lib/date/constants.rb +++ b/lib/date/constants.rb @@ -72,14 +72,14 @@ class Date # Days in each month (non-leap and leap year) MONTH_DAYS = [ - [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31], # non-leap - [0, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] # leap + [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31].freeze, # non-leap + [0, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31].freeze # leap ].freeze private_constant :MONTH_DAYS YEARTAB = [ - [0, 0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334], # non-leap - [0, 0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335] # leap + [0, 0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334].freeze, # non-leap + [0, 0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335].freeze # leap ].freeze private_constant :YEARTAB diff --git a/lib/date/core.rb b/lib/date/core.rb index 03b6d9c..ff00087 100644 --- a/lib/date/core.rb +++ b/lib/date/core.rb @@ -29,6 +29,50 @@ class Date # # Related: Date.jd. def initialize(year = -4712, month = 1, day = 1, start = DEFAULT_SG) + # Fast path: Integer arguments, Gregorian calendar + # Avoids self.class.send, Hash allocation, redundant decode_year + if year.is_a?(Integer) && year.abs < 579000 && month.is_a?(Integer) && day.is_a?(Integer) + gregorian_fast = if start.is_a?(Float) && start.infinite? + start < 0 # GREGORIAN (-Infinity) only + elsif start.is_a?(Integer) && start >= REFORM_BEGIN_JD && start <= REFORM_END_JD + year > REFORM_END_YEAR + end + + if gregorian_fast + m = month + m += 13 if m < 0 + raise Error unless m >= 1 && m <= 12 + + leap = (year % 4 == 0) && (year % 100 != 0 || year % 400 == 0) + last = MONTH_DAYS[leap ? 1 : 0][m] + d = day + d = last + d + 1 if d < 0 + raise Error unless d >= 1 && d <= last + + j = (m < 3) ? 1 : 0 + y0 = year - j + m0 = j.nonzero? ? m + 12 : m + d0 = d - 1 + q1 = y0 / 100 + yc = (NS_DAYS_IN_4_YEARS * y0) / 4 - q1 + q1 / 4 + mc = (NS_DAYS_BEFORE_NEW_YEAR * m0 - 914) / 10 + + @nth = 0 + @jd = yc + mc + d0 + NS_EPOCH + @sg = start + @year = year + @month = m + @day = d + @has_jd = true + @has_civil = true + @df = nil + @sf = nil + @of = nil + return self + end + end + + # Original path: handles non-Integer args, Julian/reform dates, fractional days y = year m = month d = day @@ -135,6 +179,21 @@ class << self # # Related: Date.jd, Date.new. def valid_civil?(year, month, day, start = DEFAULT_SG) + # Fast path: Integer args, non-Julian start + if year.is_a?(Integer) && month.is_a?(Integer) && day.is_a?(Integer) + gregorian_fast = if start.is_a?(Float) && start.infinite? + start < 0 + elsif start.is_a?(Integer) && start >= REFORM_BEGIN_JD && start <= REFORM_END_JD + true + end + + if gregorian_fast + return false if month < 1 || month > 12 + leap = (year % 4 == 0) && (year % 100 != 0 || year % 400 == 0) + return day >= 1 && day <= MONTH_DAYS[leap ? 1 : 0][month] + end + end + return false unless numeric?(year) return false unless numeric?(month) return false unless numeric?(day) @@ -170,6 +229,32 @@ def valid_civil?(year, month, day, start = DEFAULT_SG) # # Related: Date.new. def jd(jd = 0, start = DEFAULT_SG) + # Fast path: Integer jd in common range, valid start + if jd.is_a?(Integer) && jd >= 0 && jd < CM_PERIOD + valid_start = if start.is_a?(Float) && start.infinite? + true + elsif start.is_a?(Integer) && start >= REFORM_BEGIN_JD && start <= REFORM_END_JD + true + end + + if valid_start + obj = allocate + obj.instance_variable_set(:@nth, 0) + obj.instance_variable_set(:@jd, jd) + obj.instance_variable_set(:@sg, start) + obj.instance_variable_set(:@year, 0) + obj.instance_variable_set(:@month, 0) + obj.instance_variable_set(:@day, 0) + obj.instance_variable_set(:@has_jd, true) + obj.instance_variable_set(:@has_civil, false) + obj.instance_variable_set(:@df, nil) + obj.instance_variable_set(:@sf, nil) + obj.instance_variable_set(:@of, nil) + return obj + end + end + + # Original path j = 0 fr = 0 sg = start @@ -203,11 +288,11 @@ def jd(jd = 0, start = DEFAULT_SG) # # Related: Date.jd. def valid_jd?(jd, start = DEFAULT_SG) - return false unless numeric?(jd) + # All Numeric jd values are valid; skip valid_jd_sub/valid_sg chain + return true if jd.is_a?(Numeric) + return true if jd.respond_to?(:to_int) - result = valid_jd_sub(jd, start, 0) - - !result.nil? + false end # call-seq: @@ -221,6 +306,9 @@ def valid_jd?(jd, start = DEFAULT_SG) # # Related: Date.julian_leap?. def gregorian_leap?(year) + if year.is_a?(Integer) + return (year % 4 == 0) && (year % 100 != 0 || year % 400 == 0) + end raise TypeError, "invalid year (not numeric)" unless numeric?(year) _, ry = decode_year(year, -1) @@ -240,6 +328,9 @@ def gregorian_leap?(year) # # Related: Date.gregorian_leap?. def julian_leap?(year) + if year.is_a?(Integer) + return (year % 4).zero? + end raise TypeError, "invalid year (not numeric)" unless numeric?(year) _, ry = decode_year(year, +1) @@ -276,6 +367,40 @@ def julian_leap?(year) # # Related: Date.jd, Date.new. def ordinal(year = -4712, yday = 1, start = DEFAULT_SG) + # Fast path: Integer args, Gregorian calendar + if year.is_a?(Integer) && year.abs < 579000 && yday.is_a?(Integer) + gregorian_fast = if start.is_a?(Float) && start.infinite? + start < 0 + elsif start.is_a?(Integer) && start >= REFORM_BEGIN_JD && start <= REFORM_END_JD + year > REFORM_END_YEAR + end + + if gregorian_fast + leap = (year % 4 == 0) && (year % 100 != 0 || year % 400 == 0) + days_in_year = leap ? 366 : 365 + d = yday + d = days_in_year + d + 1 if d < 0 + raise Error unless d >= 1 && d <= days_in_year + + rjd = c_gregorian_civil_to_jd(year, 1, 1) + d - 1 + + obj = allocate + obj.instance_variable_set(:@nth, 0) + obj.instance_variable_set(:@jd, rjd) + obj.instance_variable_set(:@sg, start) + obj.instance_variable_set(:@year, 0) + obj.instance_variable_set(:@month, 0) + obj.instance_variable_set(:@day, 0) + obj.instance_variable_set(:@has_jd, true) + obj.instance_variable_set(:@has_civil, false) + obj.instance_variable_set(:@df, nil) + obj.instance_variable_set(:@sf, nil) + obj.instance_variable_set(:@of, nil) + return obj + end + end + + # Original path y = year d = yday fr2 = 0 @@ -317,6 +442,23 @@ def ordinal(year = -4712, yday = 1, start = DEFAULT_SG) # # Related: Date.jd, Date.ordinal. def valid_ordinal?(year, day, start = DEFAULT_SG) + # Fast path: Integer args, non-Julian start + if year.is_a?(Integer) && day.is_a?(Integer) + gregorian_fast = if start.is_a?(Float) && start.infinite? + start < 0 + elsif start.is_a?(Integer) && start >= REFORM_BEGIN_JD && start <= REFORM_END_JD + true + end + + if gregorian_fast + leap = (year % 4 == 0) && (year % 100 != 0 || year % 400 == 0) + days_in_year = leap ? 366 : 365 + d = day + d = days_in_year + d + 1 if d < 0 + return d >= 1 && d <= days_in_year + end + end + return false unless numeric?(year) return false unless numeric?(day) @@ -368,6 +510,55 @@ def valid_ordinal?(year, day, start = DEFAULT_SG) # # Related: Date.jd, Date.new, Date.ordinal. def commercial(cwyear = -4712, cweek = 1, cwday = 1, start = DEFAULT_SG) + # Fast path: Integer args, Gregorian calendar + if cwyear.is_a?(Integer) && cwyear.abs < 579000 && cweek.is_a?(Integer) && cwday.is_a?(Integer) + gregorian_fast = if start.is_a?(Float) && start.infinite? + start < 0 + elsif start.is_a?(Integer) && start >= REFORM_BEGIN_JD && start <= REFORM_END_JD + cwyear > REFORM_END_YEAR + end + + if gregorian_fast + # Validate cwday (handle negative) + d = cwday + d += 8 if d < 0 + raise Error unless d >= 1 && d <= 7 + + # JD of Jan 1 and ISO week metadata + jd_jan1 = c_gregorian_civil_to_jd(cwyear, 1, 1) + + # Max ISO weeks: 53 if Jan 1 is Thursday, or leap year and Jan 1 is Wednesday + p_val = (jd_jan1 + 1) % 7 # 0=Sun..6=Sat + p_val = 7 if p_val == 0 # Convert to ISO (1=Mon..7=Sun) + leap = (cwyear % 4 == 0) && (cwyear % 100 != 0 || cwyear % 400 == 0) + max_weeks = (p_val == 4 || (leap && p_val == 3)) ? 53 : 52 + + # Handle negative week + w = cweek + w = max_weeks + w + 1 if w < 0 + raise Error unless w >= 1 && w <= max_weeks + + # Compute JD: Monday of week 1 + offset + rjd2 = jd_jan1 + 3 + rjd = (rjd2 - (rjd2 % 7)) + 7 * (w - 1) + (d - 1) + + obj = allocate + obj.instance_variable_set(:@nth, 0) + obj.instance_variable_set(:@jd, rjd) + obj.instance_variable_set(:@sg, start) + obj.instance_variable_set(:@year, 0) + obj.instance_variable_set(:@month, 0) + obj.instance_variable_set(:@day, 0) + obj.instance_variable_set(:@has_jd, true) + obj.instance_variable_set(:@has_civil, false) + obj.instance_variable_set(:@df, nil) + obj.instance_variable_set(:@sf, nil) + obj.instance_variable_set(:@of, nil) + return obj + end + end + + # Original path y = cwyear w = cweek d = cwday @@ -418,6 +609,34 @@ def commercial(cwyear = -4712, cweek = 1, cwday = 1, start = DEFAULT_SG) # # Related: Date.jd, Date.commercial. def valid_commercial?(year, week, day, start = DEFAULT_SG) + # Fast path: Integer args, non-Julian start + if year.is_a?(Integer) && week.is_a?(Integer) && day.is_a?(Integer) + gregorian_fast = if start.is_a?(Float) && start.infinite? + start < 0 + elsif start.is_a?(Integer) && start >= REFORM_BEGIN_JD && start <= REFORM_END_JD + true + end + + if gregorian_fast + # Validate cwday (handle negative) + d = day + d += 8 if d < 0 + return false unless d >= 1 && d <= 7 + + # Max ISO weeks: 53 if Jan 1 is Thursday, or leap year and Jan 1 is Wednesday + jd_jan1 = c_gregorian_civil_to_jd(year, 1, 1) + p_val = (jd_jan1 + 1) % 7 # 0=Sun..6=Sat + p_val = 7 if p_val == 0 # Convert to ISO (1=Mon..7=Sun) + leap = (year % 4 == 0) && (year % 100 != 0 || year % 400 == 0) + max_weeks = (p_val == 4 || (leap && p_val == 3)) ? 53 : 52 + + # Handle negative week + w = week + w = max_weeks + w + 1 if w < 0 + return w >= 1 && w <= max_weeks + end + end + return false unless numeric?(year) return false unless numeric?(week) return false unless numeric?(day) @@ -436,40 +655,18 @@ def valid_commercial?(year, week, day, start = DEFAULT_SG) # # See argument {start}[rdoc-ref:language/calendars.rdoc@Argument+start]. def today(start = DEFAULT_SG) - begin - time = Time.now - rescue - raise SystemCallError, "time" - end - - begin - y = time.year - m = time.month - d = time.day - rescue - raise SystemCallError, "localtime" - end - - nth, ry, _, _ = decode_year(y, -1) + time = Time.now obj = allocate - obj.instance_variable_set(:@nth, nth) - obj.instance_variable_set(:@year, ry) - obj.instance_variable_set(:@month, m) - obj.instance_variable_set(:@day, d) + obj.instance_variable_set(:@nth, 0) + obj.instance_variable_set(:@year, time.year) + obj.instance_variable_set(:@month, time.mon) + obj.instance_variable_set(:@day, time.mday) obj.instance_variable_set(:@jd, nil) - obj.instance_variable_set(:@sg, GREGORIAN) + obj.instance_variable_set(:@sg, start) obj.instance_variable_set(:@has_jd, false) obj.instance_variable_set(:@has_civil, true) - if start != GREGORIAN - obj.instance_variable_set(:@sg, start) - if obj.instance_variable_get(:@has_jd) - obj.instance_variable_set(:@jd, nil) - obj.instance_variable_set(:@has_jd, false) - end - end - obj end @@ -808,9 +1005,7 @@ def valid_civil_sub(year, month, day, start, need_jd) return nil if month < 1 || month > 12 leap_year = start == JULIAN ? julian_leap?(year) : gregorian_leap?(year) - - days_in_month = [nil, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] - max_day = (month == 2 && leap_year) ? 29 : days_in_month[month] + max_day = MONTH_DAYS[leap_year ? 1 : 0][month] return nil if day < 1 || day > max_day @@ -1137,13 +1332,7 @@ def c_gregorian_ldom_jd(year, month) end def c_gregorian_last_day_of_month(year, month) - days_in_month = [nil, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] - - if month == 2 && gregorian_leap?(year) - 29 - else - days_in_month[month] - end + MONTH_DAYS[gregorian_leap?(year) ? 1 : 0][month] end def c_civil_to_jd(year, month, day, sg) @@ -1341,18 +1530,7 @@ def new_with_jd(nth, jd, start) def new_with_jd_and_time(nth, jd, df, sf, of, start) obj = allocate - obj.instance_variable_set(:@nth, nth) - obj.instance_variable_set(:@jd, jd) - obj.instance_variable_set(:@sg, start) - obj.instance_variable_set(:@df, df) - obj.instance_variable_set(:@sf, sf) - obj.instance_variable_set(:@of, of) - obj.instance_variable_set(:@year, nil) - obj.instance_variable_set(:@month, nil) - obj.instance_variable_set(:@day, nil) - obj.instance_variable_set(:@has_jd, true) - obj.instance_variable_set(:@has_civil, false) - + obj.send(:_init_with_jd, nth, jd, df, sf, of, start) obj end @@ -1586,6 +1764,16 @@ def start # # d <=> Object.new # => nil def <=>(other) + if other.is_a?(Date) && + @df.nil? && @sf.nil? && @of.nil? && @nth == 0 && @has_jd && + other.instance_variable_get(:@nth) == 0 && + other.instance_variable_get(:@has_jd) && + other.instance_variable_get(:@df).nil? && + other.instance_variable_get(:@sf).nil? && + other.instance_variable_get(:@of).nil? + return @jd <=> other.instance_variable_get(:@jd) + end + case other when Date m_canonicalize_jd @@ -1625,6 +1813,32 @@ def <=>(other) end end + def <(other) # :nodoc: + if other.is_a?(Date) && + @df.nil? && @sf.nil? && @of.nil? && @nth == 0 && @has_jd && + other.instance_variable_get(:@nth) == 0 && + other.instance_variable_get(:@has_jd) && + other.instance_variable_get(:@df).nil? && + other.instance_variable_get(:@sf).nil? && + other.instance_variable_get(:@of).nil? + return @jd < other.instance_variable_get(:@jd) + end + super + end + + def >(other) # :nodoc: + if other.is_a?(Date) && + @df.nil? && @sf.nil? && @of.nil? && @nth == 0 && @has_jd && + other.instance_variable_get(:@nth) == 0 && + other.instance_variable_get(:@has_jd) && + other.instance_variable_get(:@df).nil? && + other.instance_variable_get(:@sf).nil? && + other.instance_variable_get(:@of).nil? + return @jd > other.instance_variable_get(:@jd) + end + super + end + # call-seq: # self === other -> true, false, or nil. # @@ -1659,6 +1873,17 @@ def <=>(other) # # d === Object.new # => nil def ===(other) + if other.is_a?(Date) && + @df.nil? && @sf.nil? && @of.nil? && @nth == 0 && @has_jd && + @jd >= 0 && @jd < CM_PERIOD && + other.instance_variable_get(:@nth) == 0 && + other.instance_variable_get(:@has_jd) && + other.instance_variable_get(:@df).nil? && + other.instance_variable_get(:@sf).nil? && + other.instance_variable_get(:@of).nil? + return @jd == other.instance_variable_get(:@jd) + end + return equal_gen(other) unless other.is_a?(Date) # Call equal_gen even if the Gregorian calendars do not match. @@ -1700,6 +1925,29 @@ def ===(other) # d1 = d0 >> 1 # => # # d2 = d1 >> -1 # => # def >>(n) + if n.is_a?(Integer) && @of.nil? && @nth == 0 && @has_civil + sg = @sg + gregorian_fast = if sg.is_a?(Float) && sg.infinite? + sg < 0 + elsif sg.is_a?(Integer) && sg >= REFORM_BEGIN_JD && sg <= REFORM_END_JD + @year > REFORM_END_YEAR + end + if gregorian_fast + t = @year * 12 + (@month - 1) + n + y = t / 12 + m = (t % 12) + 1 + d = @day + + leap = (y % 4 == 0) && (y % 100 != 0 || y % 400 == 0) + last = MONTH_DAYS[leap ? 1 : 0][m] + d = last if d > last + + obj = self.class.allocate + obj._init_simple_with_civil(y, m, d, sg) + return obj + end + end + # Calculate years and months t = m_real_year * 12 + (m_mon - 1) + n @@ -1758,12 +2006,71 @@ def >>(n) # d1 = d0 << 1 # => # # d2 = d1 << -1 # => # def <<(n) + if n.is_a?(Integer) && @of.nil? && @nth == 0 && @has_civil + sg = @sg + gregorian_fast = if sg.is_a?(Float) && sg.infinite? + sg < 0 + elsif sg.is_a?(Integer) && sg >= REFORM_BEGIN_JD && sg <= REFORM_END_JD + @year > REFORM_END_YEAR + end + if gregorian_fast + t = @year * 12 + (@month - 1) - n + y = t / 12 + m = (t % 12) + 1 + d = @day + + leap = (y % 4 == 0) && (y % 100 != 0 || y % 400 == 0) + last = MONTH_DAYS[leap ? 1 : 0][m] + d = last if d > last + + obj = self.class.allocate + obj._init_simple_with_civil(y, m, d, sg) + return obj + end + end + raise TypeError, "expected numeric" unless n.is_a?(Numeric) - self >> (-n) + t = m_real_year * 12 + (m_mon - 1) - n + + if t.is_a?(Integer) && t.abs < (1 << 62) + y = t / 12 + m = (t % 12) + 1 + else + y = t.div(12) + m = (t % 12).to_i + 1 + end + + d = m_mday + sg = m_sg + + result = nil + loop do + result = self.class.send(:valid_civil_p, y, m, d, sg) + break if result + + d -= 1 + raise Error if d < 1 + end + + nth = result[:nth] + rjd = result[:rjd] + rjd2 = self.class.send(:encode_jd, nth, rjd) + + self + (rjd2 - m_real_local_jd) end def ==(other) # :nodoc: + if other.is_a?(Date) && + @df.nil? && @sf.nil? && @of.nil? && @nth == 0 && @has_jd && + other.instance_variable_get(:@nth) == 0 && + other.instance_variable_get(:@has_jd) && + other.instance_variable_get(:@df).nil? && + other.instance_variable_get(:@sf).nil? && + other.instance_variable_get(:@of).nil? + return @jd == other.instance_variable_get(:@jd) + end + return false unless other.is_a?(Date) m_canonicalize_jd @@ -1776,6 +2083,17 @@ def ==(other) # :nodoc: end def eql?(other) # :nodoc: + if other.is_a?(Date) && + @df.nil? && @sf.nil? && @of.nil? && @nth == 0 && @has_jd && + other.instance_variable_get(:@nth) == 0 && + other.instance_variable_get(:@has_jd) && + other.instance_variable_get(:@df).nil? && + other.instance_variable_get(:@sf).nil? && + other.instance_variable_get(:@of).nil? + return @jd == other.instance_variable_get(:@jd) && + @sg == other.instance_variable_get(:@sg) + end + return false unless other.is_a?(Date) m_canonicalize_jd @@ -1787,6 +2105,10 @@ def eql?(other) # :nodoc: end def hash # :nodoc: + if @df.nil? && @sf.nil? && @of.nil? && @nth == 0 && @has_jd && + @jd >= 0 && @jd < CM_PERIOD + return [0, @jd, @sg].hash + end m_canonicalize_jd [m_nth, m_jd, @sg].hash end @@ -1806,6 +2128,34 @@ def hash # :nodoc: # DateTime.jd(0,12) + DateTime.new(2001,2,3).ajd # #=> # def +(other) + if other.is_a?(Integer) && @of.nil? && @nth == 0 + if @has_jd + jd = @jd + other + elsif @has_civil + sg = @sg + gregorian_fast = if sg.is_a?(Float) && sg.infinite? + sg < 0 + elsif sg.is_a?(Integer) && sg >= REFORM_BEGIN_JD && sg <= REFORM_END_JD + @year > REFORM_END_YEAR + end + if gregorian_fast + j = (@month < 3) ? 1 : 0 + y0 = @year - j + m0 = j.nonzero? ? @month + 12 : @month + d0 = @day - 1 + q1 = y0 / 100 + yc = (NS_DAYS_IN_4_YEARS * y0) / 4 - q1 + q1 / 4 + mc = (NS_DAYS_BEFORE_NEW_YEAR * m0 - 914) / 10 + jd = yc + mc + d0 + NS_EPOCH + other + end + end + if jd && jd >= 0 && jd < CM_PERIOD + obj = self.class.allocate + obj._init_simple_with_jd(jd, @sg) + return obj + end + end + case other when Integer nth = m_nth @@ -1821,11 +2171,13 @@ def +(other) nth, jd = canonicalize_jd(nth, jd) end + obj = self.class.allocate if simple_dat_p? - self.class.send(:new_with_jd, nth, jd, @sg) + obj._init_with_jd(nth, jd, nil, nil, nil, @sg) else - self.class.send(:new_with_jd_and_time, nth, jd, @df || 0, @sf || 0, @of || 0, @sg) + obj._init_with_jd(nth, jd, @df || 0, @sf || 0, @of || 0, @sg) end + obj when Float s = other >= 0 ? 1 : -1 o = other.abs @@ -1884,11 +2236,13 @@ def +(other) nth = nth.nonzero? ? @nth + nth : @nth + obj = self.class.allocate if df.zero? && sf.zero? && (@of.nil? || @of.zero?) - self.class.send(:new_with_jd, nth, jd, @sg) + obj._init_with_jd(nth, jd, nil, nil, nil, @sg) else - self.class.send(:new_with_jd_and_time, nth, jd, df, sf, @of || 0, @sg) + obj._init_with_jd(nth, jd, df, sf, @of || 0, @sg) end + obj when Rational return self + other.numerator if other.denominator == 1 @@ -1949,11 +2303,13 @@ def +(other) nth = nth.nonzero? ? @nth + nth : @nth + obj = self.class.allocate if df.zero? && sf.zero? - self.class.send(:new_with_jd, nth, jd, @sg) + obj._init_with_jd(nth, jd, nil, nil, nil, @sg) else - self.class.send(:new_with_jd_and_time, nth, jd, df, sf, @of || 0, @sg) + obj._init_with_jd(nth, jd, df, sf, @of || 0, @sg) end + obj else raise TypeError, "expected numeric" unless other.is_a?(Numeric) @@ -1979,6 +2335,15 @@ def +(other) # 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) + if other.is_a?(Integer) && @of.nil? && @nth == 0 && @has_jd + jd = @jd - other + if jd >= 0 && jd < CM_PERIOD + obj = self.class.allocate + obj._init_simple_with_jd(jd, @sg) + return obj + end + end + return minus_dd(other) if other.is_a?(Date) raise TypeError, "expected numeric" unless other.is_a?(Numeric) @@ -2833,6 +3198,40 @@ def valid_civil_date?(year, month, day, sg) self.class.send(:valid_civil_date?, year, month, day, sg) end + protected + + def _init_with_jd(nth, jd, df, sf, of, start) + @nth = nth + @jd = jd + @sg = start + @df = df + @sf = sf + @of = of + @year = nil + @month = nil + @day = nil + @has_jd = true + @has_civil = false + end + + def _init_simple_with_jd(jd, start) + @nth = 0 + @jd = jd + @sg = start + @has_jd = true + end + + def _init_simple_with_civil(year, month, day, start) + @nth = 0 + @sg = start + @year = year + @month = month + @day = day + @has_civil = true + end + + private + def canonicalize_jd(nth, jd) if jd < 0 nth = nth - 1 @@ -3114,6 +3513,8 @@ def m_of end def m_real_year + return @year if @nth == 0 && @has_civil + nth = @nth year = m_year @@ -3123,14 +3524,16 @@ def m_real_year end def m_mon - simple_dat_p? ? get_s_civil : get_c_civil + return @month if @has_civil + simple_dat_p? ? get_s_civil : get_c_civil @month end def m_mday - simple_dat_p? ? get_s_civil : get_c_civil + return @day if @has_civil + simple_dat_p? ? get_s_civil : get_c_civil @day end @@ -3341,6 +3744,8 @@ def c_valid_start_p?(sg) end def m_wday + return (@jd + 1) % 7 if @has_jd && @of.nil? + c_jd_to_wday(m_local_jd) end @@ -3349,6 +3754,15 @@ def c_jd_to_wday(jd) end def m_yday + if @has_civil && @of.nil? + sg = @sg + if (sg.is_a?(Float) && sg.infinite? && sg < 0) || + (sg.is_a?(Integer) && @has_jd && (@jd - sg) > 366) + leap = self.class.send(:c_gregorian_leap_p?, @year) + return YEARTAB[leap ? 1 : 0][@month] + @day + end + end + jd = m_local_jd sg = m_virtual_sg