diff --git a/Src/Libraries/Builtins/RubyTimeOps.cs b/Src/Libraries/Builtins/RubyTimeOps.cs index 092b0f9d..061a40e6 100644 --- a/Src/Libraries/Builtins/RubyTimeOps.cs +++ b/Src/Libraries/Builtins/RubyTimeOps.cs @@ -927,9 +927,11 @@ private static int GetYear(RubyContext/*!*/ context, object[]/*!*/ components, i Invoke(context, ref _nameSite, "name", self.ZoneObject)); } else { // A time made with a numeric offset has no zone to name, and MRI writes nil. + // On Windows the name is the localized one ("Mitteleuropäische Zeit"), as in #zone. string zoneName = self.HasFixedOffset ? null : self.GetZoneName(); - context.SetInstanceVariable(result, "zone", - zoneName != null ? MutableString.CreateAscii(zoneName).Freeze() : null); + context.SetInstanceVariable(result, "zone", zoneName == null ? null : (zoneName.IsAscii() + ? MutableString.CreateAscii(zoneName) + : MutableString.Create(zoneName, context.GetPathEncoding())).Freeze()); } } diff --git a/Src/Libraries/Builtins/RubyTimeSupport.cs b/Src/Libraries/Builtins/RubyTimeSupport.cs index 4e0aef0a..883c8d90 100644 --- a/Src/Libraries/Builtins/RubyTimeSupport.cs +++ b/Src/Libraries/Builtins/RubyTimeSupport.cs @@ -476,49 +476,88 @@ namespace IronRuby.Builtins { /// /// A time zone as Ruby sees it. Backed by the zoneinfo file when one exists, because that - /// carries the DST transitions and the POSIX abbreviations that Ruby reports; otherwise a - /// plain fixed offset parsed out of a POSIX TZ string. + /// carries the DST transitions and the POSIX abbreviations that Ruby reports; on Windows, + /// which has no zoneinfo, by the .NET TimeZoneInfo; otherwise a plain fixed offset parsed + /// out of a POSIX TZ string. /// public sealed class RubyTimeZone { - private readonly TzFile _file; // null for a fixed-offset zone + private readonly TzFile _file; // null unless backed by zoneinfo or a POSIX rule + private readonly TimeZoneInfo _clrZone; // null unless backed by a .NET zone private readonly long _fixedOffset; // seconds east of UTC private readonly string/*!*/ _name; - public static readonly RubyTimeZone/*!*/ Utc = new RubyTimeZone(null, 0, "UTC"); + public static readonly RubyTimeZone/*!*/ Utc = new RubyTimeZone(null, null, 0, "UTC"); - private RubyTimeZone(TzFile file, long fixedOffset, string/*!*/ name) { + private RubyTimeZone(TzFile file, TimeZoneInfo clrZone, long fixedOffset, string/*!*/ name) { _file = file; + _clrZone = clrZone; _fixedOffset = fixedOffset; _name = name; } internal static RubyTimeZone/*!*/ MakeFixed(long offsetSeconds, string/*!*/ name) { - return new RubyTimeZone(null, offsetSeconds, name); + return new RubyTimeZone(null, null, offsetSeconds, name); } internal static RubyTimeZone/*!*/ FromPosixRule(string/*!*/ stdName, long stdOffset, string/*!*/ dstName, long dstOffset, string startRule, string endRule) { TzFile file = TzFile.FromPosixRule(stdName, (int)stdOffset, dstName, (int)dstOffset, startRule, endRule); - return new RubyTimeZone(file, 0, stdName); + return new RubyTimeZone(file, null, 0, stdName); + } + + /// + /// A .NET zone. It has to be asked for the offset of each instant: its BaseUtcOffset is + /// the standard offset only, an hour off all summer in a zone with daylight saving time. + /// + internal static RubyTimeZone/*!*/ FromTimeZoneInfo(TimeZoneInfo/*!*/ zone) { + return new RubyTimeZone(null, zone, 0, zone.StandardName); } internal static RubyTimeZone FromId(string/*!*/ id) { TzFile file = TzFile.TryLoad(id); - return (file != null) ? new RubyTimeZone(file, 0, id) : null; + if (file != null) { + return new RubyTimeZone(file, null, 0, id); + } + + // Windows has no zoneinfo files, but its TimeZoneInfo knows both the Windows ids + // ("W. Europe Standard Time") and, through ICU, the Olson ones ("Europe/Zurich"). + if (RubyFileOps.IsWindows) { + try { + return FromTimeZoneInfo(TimeZoneInfo.FindSystemTimeZoneById(id)); + } catch (TimeZoneNotFoundException) { + } catch (InvalidTimeZoneException) { + } catch (System.Security.SecurityException) { + } catch (ArgumentException) { + } + } + return null; } /// The machine's zone, used when TZ is unset. internal static RubyTimeZone/*!*/ Machine() { TzFile file = TzFile.TryLoadFile("/etc/localtime"); if (file != null) { - return new RubyTimeZone(file, 0, System.TimeZoneInfo.Local.Id); + return new RubyTimeZone(file, null, 0, System.TimeZoneInfo.Local.Id); } - var local = System.TimeZoneInfo.Local; - return MakeFixed((long)local.BaseUtcOffset.TotalSeconds, local.StandardName); + return FromTimeZoneInfo(System.TimeZoneInfo.Local); + } + + /// The instant as a UTC DateTime, clamped to the range DateTime can hold. + private static DateTime ToUtcDateTime(long unixSeconds) { + long min = (DateTime.MinValue.Ticks - RubyTime.Epoch.Ticks) / TimeSpan.TicksPerSecond; + long max = (DateTime.MaxValue.Ticks - RubyTime.Epoch.Ticks) / TimeSpan.TicksPerSecond; + unixSeconds = Math.Max(min, Math.Min(max, unixSeconds)); + return RubyTime.Epoch.AddTicks(unixSeconds * TimeSpan.TicksPerSecond); } internal long GetOffset(long unixSeconds) { - return (_file != null) ? _file.GetOffset(unixSeconds) : _fixedOffset; + if (_file != null) { + return _file.GetOffset(unixSeconds); + } + if (_clrZone != null) { + return (long)_clrZone.GetUtcOffset(ToUtcDateTime(unixSeconds)).TotalSeconds; + } + return _fixedOffset; } /// @@ -529,13 +568,13 @@ internal long GetOffset(long unixSeconds) { /// never occurs (the clocks went forward) is taken with the offset from before the change. /// internal long GetOffsetForWallClock(long wallSeconds, bool preferDst = false) { - if (_file == null) { + if (_file == null && _clrZone == null) { return _fixedOffset; } - long before = _file.GetOffset(wallSeconds - 86400); - long after = _file.GetOffset(wallSeconds + 86400); - bool beforeFits = _file.GetOffset(wallSeconds - before) == before; - bool afterFits = _file.GetOffset(wallSeconds - after) == after; + long before = GetOffset(wallSeconds - 86400); + long after = GetOffset(wallSeconds + 86400); + bool beforeFits = GetOffset(wallSeconds - before) == before; + bool afterFits = GetOffset(wallSeconds - after) == after; if (beforeFits && afterFits) { return preferDst ? Math.Max(before, after) : Math.Min(before, after); } @@ -546,9 +585,9 @@ internal long GetOffsetForWallClock(long wallSeconds, bool preferDst = false) { return before; } // neither fits: a gap, or more than one change within the day - long guess = _file.GetOffset(wallSeconds); - long second = _file.GetOffset(wallSeconds - guess); - return (_file.GetOffset(wallSeconds - second) == second) ? second : before; + long guess = GetOffset(wallSeconds); + long second = GetOffset(wallSeconds - guess); + return (GetOffset(wallSeconds - second) == second) ? second : before; } internal string/*!*/ GetAbbreviation(long unixSeconds) { @@ -558,11 +597,18 @@ internal long GetOffsetForWallClock(long wallSeconds, bool preferDst = false) { return result; } } + if (_clrZone != null && IsDaylightSavingTime(unixSeconds)) { + // .NET has no abbreviations, only names such as "W. Europe Daylight Time" + return _clrZone.DaylightName; + } return _name; } internal bool IsDaylightSavingTime(long unixSeconds) { - return _file != null && _file.IsDaylightSavingTime(unixSeconds); + if (_file != null) { + return _file.IsDaylightSavingTime(unixSeconds); + } + return _clrZone != null && _clrZone.IsDaylightSavingTime(ToUtcDateTime(unixSeconds)); } internal string/*!*/ Name { diff --git a/Util/time-local-matrix.rb b/Util/time-local-matrix.rb new file mode 100644 index 00000000..2a6e4708 --- /dev/null +++ b/Util/time-local-matrix.rb @@ -0,0 +1,75 @@ +# Cross-checks Time's local zone - utc_offset, dst? and the wall clock - against CRuby, +# for the machine's own zone (TZ unset) across a year, through both daylight saving +# changes of the European and the US rules, and for wall-clock readings in the gaps and +# overlaps. +# +# ruby Util/time-local-matrix.rb > /tmp/cruby.txt +# ./ir.sh Util/time-local-matrix.rb > /tmp/ir.txt +# diff -u /tmp/cruby.txt /tmp/ir.txt +# +# Run both with TZ unset and on the same machine: the expected values are whatever that +# machine's zone says, so the script is only interesting in a zone that has daylight saving +# time (Europe/Zurich, America/New_York, ...). A zone that IronRuby resolves to a fixed +# offset passes in winter and fails every summer line. Zone names are left out because +# CRuby on Windows reports the C runtime's names, not the POSIX abbreviations. + +def fmt(time) + format("%04d-%02d-%02d %02d:%02d:%02d off=%d dst=%p", time.year, time.month, time.day, + time.hour, time.min, time.sec, time.utc_offset, time.dst?) +end + +def t(label) + puts "#{label}: #{yield}" +rescue Exception => e + puts "#{label}: RAISED #{e.class}: #{e.message}" +end + +# Time.now: the offset must be the one in force now, not the zone's standard offset. +now = Time.now +t("now consistent") { (now.utc_offset == Time.at(now.to_i).utc_offset).inspect } +t("now wall clock") { ((now.to_i + now.utc_offset) % 86400 == (now.hour * 3600 + now.min * 60 + now.sec)).inspect } + +# Time.at and #localtime for the middle and the 1st of every month. +year = 2026 +(1..12).each do |month| + [1, 15].each do |day| + instant = Time.utc(year, month, day, 12, 0, 0).to_i + t("at #{year}-#{month}-#{day} 12:00Z") { fmt(Time.at(instant)) } + t("localtime #{year}-#{month}-#{day} 12:00Z") { fmt(Time.utc(year, month, day, 12, 0, 0).localtime) } + end +end + +# Every hour across the days of the changes: last Sunday of March and of October (EU), +# second Sunday of March and first Sunday of November (US), in UTC so the labels do not +# depend on the zone. +[[3, 28], [3, 29], [3, 7], [3, 8], [10, 24], [10, 25], [10, 31], [11, 1]].each do |month, day| + base = Time.utc(year, month, day).to_i + 24.times do |hour| + t("at #{year}-#{month}-#{day} #{hour}:30Z") { fmt(Time.at(base + hour * 3600 + 1800)) } + end +end + +# Wall-clock constructors, including the readings that do not exist (the clocks went +# forward) and the ones that occur twice (the clocks went back) under either rule. +WALL_CLOCKS = [ + [2026, 1, 15, 12, 0, 0], [2026, 7, 15, 12, 0, 0], [2026, 10, 12, 21, 20, 46], + [2026, 3, 29, 1, 30, 0], [2026, 3, 29, 2, 30, 0], [2026, 3, 29, 3, 30, 0], + [2026, 10, 25, 1, 30, 0], [2026, 10, 25, 2, 30, 0], [2026, 10, 25, 3, 30, 0], + [2026, 3, 8, 1, 30, 0], [2026, 3, 8, 2, 30, 0], [2026, 3, 8, 3, 30, 0], + [2026, 11, 1, 0, 30, 0], [2026, 11, 1, 1, 30, 0], [2026, 11, 1, 2, 30, 0], + [1999, 7, 1, 0, 0, 0], [2040, 7, 1, 0, 0, 0], +] +WALL_CLOCKS.each do |args| + label = args.join(",") + t("local #{label}") { fmt(Time.local(*args)) } + t("mktime #{label}") { fmt(Time.mktime(*args)) } + t("new #{label}") { fmt(Time.new(*args)) } +end + +# A local Time keeps its offset through arithmetic that crosses a change. +t("summer - 120 days") { fmt(Time.local(2026, 7, 15, 12) - 120 * 86400) } +t("winter + 180 days") { fmt(Time.local(2026, 1, 15, 12) + 180 * 86400) } +t("to_a summer") { Time.local(2026, 7, 15, 12).to_a.values_at(0..8).inspect } +t("strftime %z summer") { Time.local(2026, 7, 15, 12).strftime("%z") } +t("strftime %z winter") { Time.local(2026, 1, 15, 12).strftime("%z") } +t("inspect summer") { Time.local(2026, 7, 15, 12).inspect.sub(/ [A-Za-z].*\z/, "") }