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/, "") }