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