Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions Src/Libraries/Builtins/RubyTimeOps.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
}

Expand Down
88 changes: 67 additions & 21 deletions Src/Libraries/Builtins/RubyTimeSupport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -476,49 +476,88 @@ namespace IronRuby.Builtins {

/// <summary>
/// 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.
/// </summary>
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);
}

/// <summary>
/// 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.
/// </summary>
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;
}

/// <summary>The machine's zone, used when TZ is unset.</summary>
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);
}

/// <summary>The instant as a UTC DateTime, clamped to the range DateTime can hold.</summary>
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;
}

/// <summary>
Expand All @@ -529,13 +568,13 @@ internal long GetOffset(long unixSeconds) {
/// never occurs (the clocks went forward) is taken with the offset from before the change.
/// </summary>
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);
}
Expand All @@ -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) {
Expand All @@ -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 {
Expand Down
75 changes: 75 additions & 0 deletions Util/time-local-matrix.rb
Original file line number Diff line number Diff line change
@@ -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/, "") }
Loading