// The MIT License(MIT)
//
// Copyright (c) 2017 Hangfire OÜ
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Runtime.CompilerServices;
using System.Text;
namespace Cronos
{
///
/// Provides a parser and scheduler for cron expressions.
///
public sealed class CronExpression: IEquatable
{
private const long NotFound = -1;
///
/// Represents a cron expression that fires on Jan 1st every year at midnight.
/// Equals to "0 0 1 1 *".
///
public static readonly CronExpression Yearly = Parse("0 0 1 1 *", CronFormat.Standard);
///
/// Represents a cron expression that fires at an unspecified time once per year.
/// Equals to "H H H H H *".
///
public static CronExpression YearlyWithJitter(int jitterSeed) =>
Parse("H H H H H *", CronFormat.IncludeSeconds, jitterSeed);
///
/// Represents a cron expression that fires every Sunday at midnight.
/// Equals to "0 0 * * 0".
///
public static readonly CronExpression Weekly = Parse("0 0 * * 0", CronFormat.Standard);
///
/// Represents a cron expression that fires at an unspecified time once per week.
/// Equals to "H H H * * H".
///
public static CronExpression WeeklyWithJitter(int jitterSeed) =>
Parse("H H H * * H", CronFormat.IncludeSeconds, jitterSeed);
///
/// Represents a cron expression that fires on 1st day of every month at midnight.
/// Equals to "0 0 1 * *".
///
public static readonly CronExpression Monthly = Parse("0 0 1 * *", CronFormat.Standard);
///
/// Represents a cron expression that fires at an unspecified time once per month.
/// Equals to "H H H H * *".
///
public static CronExpression MonthlyWithJitter(int jitterSeed) =>
Parse("H H H H * *", CronFormat.IncludeSeconds, jitterSeed);
///
/// Represents a cron expression that fires every day at midnight.
/// Equals to "0 0 * * *".
///
public static readonly CronExpression Daily = Parse("0 0 * * *", CronFormat.Standard);
///
/// Represents a cron expression that fires at an unspecified time every day.
/// Equals to "H H H * * *".
///
public static CronExpression DailyWithJitter(int jitterSeed) =>
Parse("H H H * * *", CronFormat.IncludeSeconds, jitterSeed);
///
/// Represents a cron expression that fires every hour at the beginning of the hour.
/// Equals to "0 * * * *".
///
public static readonly CronExpression Hourly = Parse("0 * * * *", CronFormat.Standard);
///
/// Represents a cron expression that fires at an unspecified time every hour.
/// Equals to "H H * * * *".
///
public static CronExpression HourlyWithJitter(int jitterSeed) =>
Parse("H H * * * *", CronFormat.IncludeSeconds, jitterSeed);
///
/// Represents a cron expression that fires every minute.
/// Equals to "* * * * *".
///
public static readonly CronExpression EveryMinute = Parse("* * * * *", CronFormat.Standard);
///
/// Represents a cron expression that fires at an unspecified second every minute.
/// Equals to "H * * * * *".
///
public static CronExpression EveryMinuteWithJitter(int jitterSeed) =>
Parse("H * * * * *", CronFormat.IncludeSeconds, jitterSeed);
///
/// Represents a cron expression that fires every second.
/// Equals to "* * * * * *".
///
public static readonly CronExpression EverySecond = Parse("* * * * * *", CronFormat.IncludeSeconds);
private static readonly TimeZoneInfo UtcTimeZone = TimeZoneInfo.Utc;
private static readonly int[] DeBruijnPositions =
{
0, 1, 2, 53, 3, 7, 54, 27,
4, 38, 41, 8, 34, 55, 48, 28,
62, 5, 39, 46, 44, 42, 22, 9,
24, 35, 59, 56, 49, 18, 29, 11,
63, 52, 6, 26, 37, 40, 33, 47,
61, 45, 43, 21, 23, 58, 17, 10,
51, 25, 36, 32, 60, 20, 57, 16,
50, 31, 19, 15, 30, 14, 13, 12
};
private readonly ulong _second; // 60 bits -> from 0 bit to 59 bit
private readonly ulong _minute; // 60 bits -> from 0 bit to 59 bit
private readonly uint _hour; // 24 bits -> from 0 bit to 23 bit
private readonly uint _dayOfMonth; // 31 bits -> from 1 bit to 31 bit
private readonly ushort _month; // 12 bits -> from 1 bit to 12 bit
private readonly byte _dayOfWeek; // 8 bits -> from 0 bit to 7 bit
private readonly byte _nthDayOfWeek;
private readonly byte _lastMonthOffset;
private readonly CronExpressionFlag _flags;
internal CronExpression(
ulong second,
ulong minute,
uint hour,
uint dayOfMonth,
ushort month,
byte dayOfWeek,
byte nthDayOfWeek,
byte lastMonthOffset,
CronExpressionFlag flags)
{
_second = second;
_minute = minute;
_hour = hour;
_dayOfMonth = dayOfMonth;
_month = month;
_dayOfWeek = dayOfWeek;
_nthDayOfWeek = nthDayOfWeek;
_lastMonthOffset = lastMonthOffset;
_flags = flags;
}
///
/// Constructs a new based on the specified
/// cron expression. Its supported expressions consist of 5 fields:
/// minute, hour, day of month, month, day of week.
/// See more: https://github.com/HangfireIO/Cronos
///
public static CronExpression Parse(string expression)
{
return Parse(expression, CronFormat.Standard);
}
///
/// Constructs a new based on the specified
/// cron expression and jitter seed. Its supported expressions consist of 5 fields:
/// minute, hour, day of month, month, day of week.
/// See more: https://github.com/HangfireIO/Cronos
///
public static CronExpression Parse(string expression, int jitterSeed)
{
return Parse(expression, CronFormat.Standard, jitterSeed);
}
///
/// Constructs a new based on the specified
/// cron expression. Its supported expressions consist of 5 or 6 fields:
/// second (optional), minute, hour, day of month, month, day of week.
/// See more: https://github.com/HangfireIO/Cronos
///
public static CronExpression Parse(string expression, CronFormat format)
{
#if NET6_0_OR_GREATER
ArgumentNullException.ThrowIfNull(expression);
#else
if (expression == null) throw new ArgumentNullException(nameof(expression));
#endif
return CronParser.Parse(expression, format);
}
///
/// Constructs a new based on the specified
/// cron expression and jitter seed. Its supported expressions consist of 5 or 6 fields:
/// second (optional), minute, hour, day of month, month, day of week.
/// See more: https://github.com/HangfireIO/Cronos
///
public static CronExpression Parse(string expression, CronFormat format, int jitterSeed)
{
#if NET6_0_OR_GREATER
ArgumentNullException.ThrowIfNull(expression);
#else
if (expression == null) throw new ArgumentNullException(nameof(expression));
#endif
return CronParser.Parse(expression, format, jitterSeed);
}
///
/// Constructs a new based on the specified cron expression with the
/// format.
/// A return value indicates whether the operation succeeded.
///
public static bool TryParse(string expression, [MaybeNullWhen(returnValue: false)] out CronExpression cronExpression)
{
return TryParse(expression, CronFormat.Standard, out cronExpression);
}
///
/// Constructs a new based on the specified cron expression and jitter seed with
/// the format.
/// A return value indicates whether the operation succeeded.
///
public static bool TryParse(string expression, int jitterSeed, [MaybeNullWhen(returnValue: false)] out CronExpression cronExpression)
{
return TryParse(expression, CronFormat.Standard, jitterSeed, out cronExpression);
}
///
/// Constructs a new based on the specified cron expression with the specified
/// .
/// A return value indicates whether the operation succeeded.
///
public static bool TryParse(string expression, CronFormat format, [MaybeNullWhen(returnValue: false)] out CronExpression cronExpression)
{
#if NET6_0_OR_GREATER
ArgumentNullException.ThrowIfNull(expression);
#else
if (expression == null) throw new ArgumentNullException(nameof(expression));
#endif
try
{
cronExpression = Parse(expression, format);
return true;
}
catch (CronFormatException)
{
cronExpression = null;
return false;
}
catch (MissingSeedException)
{
cronExpression = null;
return false;
}
}
///
/// Constructs a new based on the specified cron expression and jitter seed with
/// the specified .
/// A return value indicates whether the operation succeeded.
///
public static bool TryParse(string expression, CronFormat format, int jitterSeed, [MaybeNullWhen(returnValue: false)] out CronExpression cronExpression)
{
#if NET6_0_OR_GREATER
ArgumentNullException.ThrowIfNull(expression);
#else
if (expression == null) throw new ArgumentNullException(nameof(expression));
#endif
try
{
cronExpression = Parse(expression, format, jitterSeed);
return true;
}
catch (CronFormatException)
{
cronExpression = null;
return false;
}
catch (MissingSeedException)
{
cronExpression = null;
return false;
}
}
///
/// Calculates next occurrence starting with (optionally ) in UTC time zone.
///
///
public DateTime? GetNextOccurrence(DateTime fromUtc, bool inclusive = false)
{
if (fromUtc.Kind != DateTimeKind.Utc) ThrowWrongDateTimeKindException(nameof(fromUtc));
var found = FindOccurrence(fromUtc.Ticks, inclusive);
if (found == NotFound) return null;
return new DateTime(found, DateTimeKind.Utc);
}
///
/// Calculates previous occurrence starting with (optionally ) in UTC time zone.
///
///
public DateTime? GetPreviousOccurrence(DateTime fromUtc, bool inclusive = false)
{
if (fromUtc.Kind != DateTimeKind.Utc) ThrowWrongDateTimeKindException(nameof(fromUtc));
var found = FindPreviousOccurrence(fromUtc.Ticks, inclusive);
if (found == NotFound) return null;
return new DateTime(found, DateTimeKind.Utc);
}
///
/// Calculates next occurrence starting with (optionally ) in given
///
///
public DateTime? GetNextOccurrence(DateTime fromUtc, TimeZoneInfo zone, bool inclusive = false)
{
if (fromUtc.Kind != DateTimeKind.Utc) ThrowWrongDateTimeKindException(nameof(fromUtc));
if (ReferenceEquals(zone, null)) ThrowArgumentNullException(nameof(zone));
if (ReferenceEquals(zone, UtcTimeZone))
{
var found = FindOccurrence(fromUtc.Ticks, inclusive);
if (found == NotFound) return null;
return new DateTime(found, DateTimeKind.Utc);
}
var fromOffset = new DateTimeOffset(fromUtc);
#pragma warning disable CA1062
var occurrence = GetOccurrenceConsideringTimeZone(fromOffset, zone, inclusive);
#pragma warning restore CA1062
return occurrence?.UtcDateTime;
}
///
/// Calculates previous occurrence starting with (optionally ) in given
///
///
public DateTime? GetPreviousOccurrence(DateTime fromUtc, TimeZoneInfo zone, bool inclusive = false)
{
if (fromUtc.Kind != DateTimeKind.Utc) ThrowWrongDateTimeKindException(nameof(fromUtc));
if (ReferenceEquals(zone, null)) ThrowArgumentNullException(nameof(zone));
if (ReferenceEquals(zone, UtcTimeZone))
{
var found = FindPreviousOccurrence(fromUtc.Ticks, inclusive);
if (found == NotFound) return null;
return new DateTime(found, DateTimeKind.Utc);
}
var fromOffset = new DateTimeOffset(fromUtc);
#pragma warning disable CA1062
var occurrence = GetPreviousOccurrenceConsideringTimeZone(fromOffset, zone, inclusive);
#pragma warning restore CA1062
return occurrence?.UtcDateTime;
}
///
/// Calculates next occurrence starting with (optionally ) in given
///
///
public DateTimeOffset? GetNextOccurrence(DateTimeOffset from, TimeZoneInfo zone, bool inclusive = false)
{
if (ReferenceEquals(zone, null)) ThrowArgumentNullException(nameof(zone));
if (ReferenceEquals(zone, UtcTimeZone))
{
var found = FindOccurrence(from.UtcTicks, inclusive);
if (found == NotFound) return null;
return new DateTimeOffset(found, TimeSpan.Zero);
}
#pragma warning disable CA1062
return GetOccurrenceConsideringTimeZone(from, zone, inclusive);
#pragma warning restore CA1062
}
///
/// Calculates previous occurrence starting with (optionally ) in given
///
///
public DateTimeOffset? GetPreviousOccurrence(DateTimeOffset from, TimeZoneInfo zone, bool inclusive = false)
{
if (ReferenceEquals(zone, null)) ThrowArgumentNullException(nameof(zone));
if (ReferenceEquals(zone, UtcTimeZone))
{
var found = FindPreviousOccurrence(from.UtcTicks, inclusive);
if (found == NotFound) return null;
return new DateTimeOffset(found, TimeSpan.Zero);
}
#pragma warning disable CA1062
return GetPreviousOccurrenceConsideringTimeZone(from, zone, inclusive);
#pragma warning restore CA1062
}
///
/// Returns the list of next occurrences within the given date/time range,
/// including and excluding
/// by default, and UTC time zone. When none of the occurrences found, an
/// empty list is returned.
///
///
public IEnumerable GetOccurrences(
DateTime fromUtc,
DateTime toUtc,
bool fromInclusive = true,
bool toInclusive = false)
{
if (fromUtc > toUtc) ThrowFromShouldBeLessThanToException(nameof(fromUtc), nameof(toUtc));
for (var occurrence = GetNextOccurrence(fromUtc, fromInclusive);
occurrence < toUtc || occurrence == toUtc && toInclusive;
// ReSharper disable once RedundantArgumentDefaultValue
// ReSharper disable once ArgumentsStyleLiteral
occurrence = GetNextOccurrence(occurrence.Value, inclusive: false))
{
yield return occurrence.Value;
}
}
///
/// Returns the list of next occurrences within the given date/time range, including
/// and excluding by default, and
/// specified time zone. When none of the occurrences found, an empty list is returned.
///
///
public IEnumerable GetOccurrences(
DateTime fromUtc,
DateTime toUtc,
TimeZoneInfo zone,
bool fromInclusive = true,
bool toInclusive = false)
{
if (fromUtc > toUtc) ThrowFromShouldBeLessThanToException(nameof(fromUtc), nameof(toUtc));
for (var occurrence = GetNextOccurrence(fromUtc, zone, fromInclusive);
occurrence < toUtc || occurrence == toUtc && toInclusive;
// ReSharper disable once RedundantArgumentDefaultValue
// ReSharper disable once ArgumentsStyleLiteral
occurrence = GetNextOccurrence(occurrence.Value, zone, inclusive: false))
{
yield return occurrence.Value;
}
}
///
/// Returns the list of occurrences within the given date/time offset range,
/// including and excluding by
/// default. When none of the occurrences found, an empty list is returned.
///
///
public IEnumerable GetOccurrences(
DateTimeOffset from,
DateTimeOffset to,
TimeZoneInfo zone,
bool fromInclusive = true,
bool toInclusive = false)
{
if (from > to) ThrowFromShouldBeLessThanToException(nameof(from), nameof(to));
for (var occurrence = GetNextOccurrence(from, zone, fromInclusive);
occurrence < to || occurrence == to && toInclusive;
// ReSharper disable once RedundantArgumentDefaultValue
// ReSharper disable once ArgumentsStyleLiteral
occurrence = GetNextOccurrence(occurrence.Value, zone, inclusive: false))
{
yield return occurrence.Value;
}
}
///
/// Returns the list of previous occurrences within the given date/time range,
/// including and excluding
/// by default, and UTC time zone. When none of the occurrences found, an
/// empty list is returned.
///
///
public IEnumerable GetOccurrencesDescending(
DateTime fromUtc,
DateTime toUtc,
bool fromInclusive = true,
bool toInclusive = false)
{
if (fromUtc < toUtc) ThrowFromShouldBeGreaterThanToException(nameof(fromUtc), nameof(toUtc));
for (var occurrence = GetPreviousOccurrence(fromUtc, fromInclusive);
occurrence > toUtc || occurrence == toUtc && toInclusive;
occurrence = GetPreviousOccurrence(occurrence.Value, inclusive: false))
{
yield return occurrence.Value;
}
}
///
/// Returns the list of previous occurrences within the given date/time range, including
/// and excluding by default, and
/// specified time zone. When none of the occurrences found, an empty list is returned.
///
///
public IEnumerable GetOccurrencesDescending(
DateTime fromUtc,
DateTime toUtc,
TimeZoneInfo zone,
bool fromInclusive = true,
bool toInclusive = false)
{
if (fromUtc < toUtc) ThrowFromShouldBeGreaterThanToException(nameof(fromUtc), nameof(toUtc));
for (var occurrence = GetPreviousOccurrence(fromUtc, zone, fromInclusive);
occurrence > toUtc || occurrence == toUtc && toInclusive;
occurrence = GetPreviousOccurrence(occurrence.Value, zone, inclusive: false))
{
yield return occurrence.Value;
}
}
///
/// Returns the list of previous occurrences within the given date/time offset range,
/// including and excluding by default.
/// When none of the occurrences found, an empty list is returned.
///
///
public IEnumerable GetOccurrencesDescending(
DateTimeOffset from,
DateTimeOffset to,
TimeZoneInfo zone,
bool fromInclusive = true,
bool toInclusive = false)
{
if (from < to) ThrowFromShouldBeGreaterThanToException(nameof(from), nameof(to));
for (var occurrence = GetPreviousOccurrence(from, zone, fromInclusive);
occurrence > to || occurrence == to && toInclusive;
occurrence = GetPreviousOccurrence(occurrence.Value, zone, inclusive: false))
{
yield return occurrence.Value;
}
}
///
public override string ToString()
{
var expressionBuilder = new StringBuilder();
if (_second != 1UL)
{
AppendFieldValue(expressionBuilder, CronField.Seconds, _second).Append(' ');
}
AppendFieldValue(expressionBuilder, CronField.Minutes, _minute).Append(' ');
AppendFieldValue(expressionBuilder, CronField.Hours, _hour).Append(' ');
AppendDayOfMonth(expressionBuilder, _dayOfMonth).Append(' ');
AppendFieldValue(expressionBuilder, CronField.Months, _month).Append(' ');
AppendDayOfWeek(expressionBuilder, _dayOfWeek);
return expressionBuilder.ToString();
}
///
/// Determines whether the specified is equal to the current .
///
/// The to compare with the current .
///
/// true if the specified is equal to the current ; otherwise, false.
///
public bool Equals(CronExpression? other)
{
if (ReferenceEquals(other, null)) return false;
return _second == other._second &&
_minute == other._minute &&
_hour == other._hour &&
_dayOfMonth == other._dayOfMonth &&
_month == other._month &&
_dayOfWeek == other._dayOfWeek &&
_nthDayOfWeek == other._nthDayOfWeek &&
_lastMonthOffset == other._lastMonthOffset &&
_flags == other._flags;
}
///
/// Determines whether the specified is equal to this instance.
///
/// The to compare with this instance.
///
/// true if the specified is equal to this instance;
/// otherwise, false.
///
public override bool Equals(object? obj) => Equals(obj as CronExpression);
///
/// Returns a hash code for this instance.
///
///
/// A hash code for this instance, suitable for use in hashing algorithms and data
/// structures like a hash table.
///
[SuppressMessage("ReSharper", "NonReadonlyMemberInGetHashCode")]
public override int GetHashCode()
{
unchecked
{
var hashCode = _second.GetHashCode();
hashCode = (hashCode * 397) ^ _minute.GetHashCode();
hashCode = (hashCode * 397) ^ _hour.GetHashCode();
hashCode = (hashCode * 397) ^ _dayOfMonth.GetHashCode();
hashCode = (hashCode * 397) ^ _month.GetHashCode();
hashCode = (hashCode * 397) ^ _dayOfWeek.GetHashCode();
hashCode = (hashCode * 397) ^ _nthDayOfWeek.GetHashCode();
hashCode = (hashCode * 397) ^ _lastMonthOffset.GetHashCode();
hashCode = (hashCode * 397) ^ (int)_flags;
return hashCode;
}
}
///
/// Implements the operator ==.
///
public static bool operator ==(CronExpression? left, CronExpression? right) => Equals(left, right);
///
/// Implements the operator !=.
///
public static bool operator !=(CronExpression? left, CronExpression? right) => !Equals(left, right);
private DateTimeOffset? GetOccurrenceConsideringTimeZone(DateTimeOffset fromUtc, TimeZoneInfo zone, bool inclusive)
{
if (!DateTimeHelper.IsRound(fromUtc))
{
// Rarely, if fromUtc is very close to DST transition, `TimeZoneInfo.ConvertTime` may not convert it correctly on Windows.
// E.g., In Jordan Time DST started 2017-03-31 00:00 local time. Clocks jump forward from `2017-03-31 00:00 +02:00` to `2017-03-31 01:00 +3:00`.
// But `2017-03-30 23:59:59.9999000 +02:00` will be converted to `2017-03-31 00:59:59.9999000 +03:00` instead of `2017-03-30 23:59:59.9999000 +02:00` on Windows.
// It can lead to skipped occurrences. To avoid such errors we floor fromUtc to seconds:
// `2017-03-30 23:59:59.9999000 +02:00` will be floored to `2017-03-30 23:59:59.0000000 +02:00` and will be converted to `2017-03-30 23:59:59.0000000 +02:00`.
fromUtc = DateTimeHelper.FloorToSeconds(fromUtc);
inclusive = false;
}
var from = TimeZoneInfo.ConvertTime(fromUtc, zone);
var fromLocal = from.DateTime;
if (TimeZoneHelper.IsAmbiguousTime(zone, fromLocal))
{
var currentOffset = from.Offset;
var standardOffset = zone.GetUtcOffset(fromLocal);
if (standardOffset != currentOffset)
{
var daylightOffset = TimeZoneHelper.GetDaylightOffset(zone, fromLocal);
var daylightTimeLocalEnd = TimeZoneHelper.GetDaylightTimeEnd(zone, fromLocal, daylightOffset).DateTime;
// Early period, try to find anything here.
var foundInDaylightOffset = FindOccurrence(fromLocal.Ticks, daylightTimeLocalEnd.Ticks, inclusive);
if (foundInDaylightOffset != NotFound) return new DateTimeOffset(foundInDaylightOffset, daylightOffset);
fromLocal = TimeZoneHelper.GetStandardTimeStart(zone, fromLocal, daylightOffset).DateTime;
inclusive = true;
}
// Skip late ambiguous interval.
var ambiguousIntervalLocalEnd = TimeZoneHelper.GetAmbiguousIntervalEnd(zone, fromLocal).DateTime;
if (HasFlag(CronExpressionFlag.Interval))
{
var foundInStandardOffset = FindOccurrence(fromLocal.Ticks, ambiguousIntervalLocalEnd.Ticks - 1, inclusive);
if (foundInStandardOffset != NotFound) return new DateTimeOffset(foundInStandardOffset, standardOffset);
}
fromLocal = ambiguousIntervalLocalEnd;
inclusive = true;
}
var occurrenceTicks = FindOccurrence(fromLocal.Ticks, inclusive);
if (occurrenceTicks == NotFound) return null;
var occurrence = new DateTime(occurrenceTicks, DateTimeKind.Unspecified);
if (zone.IsInvalidTime(occurrence))
{
var nextValidTime = TimeZoneHelper.GetDaylightTimeStart(zone, occurrence);
return nextValidTime;
}
if (TimeZoneHelper.IsAmbiguousTime(zone, occurrence))
{
var daylightOffset = TimeZoneHelper.GetDaylightOffset(zone, occurrence);
return new DateTimeOffset(occurrence, daylightOffset);
}
return new DateTimeOffset(occurrence, zone.GetUtcOffset(occurrence));
}
private DateTimeOffset? GetPreviousOccurrenceConsideringTimeZone(DateTimeOffset fromUtc, TimeZoneInfo zone, bool inclusive)
{
if (!DateTimeHelper.IsRound(fromUtc))
{
fromUtc = DateTimeHelper.FloorToSeconds(fromUtc);
}
var from = TimeZoneInfo.ConvertTime(fromUtc, zone);
var fromLocal = from.DateTime;
if (TimeZoneHelper.IsAmbiguousTime(zone, fromLocal))
{
var currentOffset = from.Offset;
var standardOffset = zone.GetUtcOffset(fromLocal);
var daylightOffset = TimeZoneHelper.GetDaylightOffset(zone, fromLocal);
var ambiguousIntervalStart = TimeZoneHelper.GetStandardTimeStart(zone, fromLocal, daylightOffset).DateTime;
if (standardOffset == currentOffset)
{
if (HasFlag(CronExpressionFlag.Interval))
{
var foundInStandardOffset = FindPreviousOccurrence(fromLocal.Ticks, ambiguousIntervalStart.Ticks, inclusive);
if (foundInStandardOffset != NotFound) return new DateTimeOffset(foundInStandardOffset, standardOffset);
}
var daylightTimeLocalEnd = TimeZoneHelper.GetDaylightTimeEnd(zone, fromLocal, daylightOffset).DateTime;
var foundInDaylightOffset = FindPreviousOccurrence(daylightTimeLocalEnd.Ticks, ambiguousIntervalStart.Ticks, true);
if (foundInDaylightOffset != NotFound) return new DateTimeOffset(foundInDaylightOffset, daylightOffset);
fromLocal = ambiguousIntervalStart.AddTicks(-1);
inclusive = true;
}
else
{
var foundInDaylightOffset = FindPreviousOccurrence(fromLocal.Ticks, ambiguousIntervalStart.Ticks, inclusive);
if (foundInDaylightOffset != NotFound) return new DateTimeOffset(foundInDaylightOffset, daylightOffset);
fromLocal = ambiguousIntervalStart.AddTicks(-1);
inclusive = true;
}
}
var occurrenceTicks = FindPreviousOccurrence(fromLocal.Ticks, inclusive);
if (occurrenceTicks == NotFound) return null;
var occurrence = new DateTime(occurrenceTicks, DateTimeKind.Unspecified);
if (zone.IsInvalidTime(occurrence))
{
var previousValidTime = TimeZoneHelper.GetStandardTimeEnd(zone, occurrence);
if (previousValidTime.Date < occurrence.Date)
{
var daylightTimeStart = TimeZoneHelper.GetDaylightTimeStart(zone, occurrence);
return new DateTimeOffset(occurrence, daylightTimeStart.Offset);
}
return previousValidTime;
}
if (TimeZoneHelper.IsAmbiguousTime(zone, occurrence))
{
var daylightOffset = TimeZoneHelper.GetDaylightOffset(zone, occurrence);
if (HasFlag(CronExpressionFlag.Interval))
{
return new DateTimeOffset(occurrence, zone.GetUtcOffset(occurrence));
}
return new DateTimeOffset(occurrence, daylightOffset);
}
return new DateTimeOffset(occurrence, zone.GetUtcOffset(occurrence));
}
private long FindOccurrence(long startTimeTicks, long endTimeTicks, bool startInclusive)
{
var found = FindOccurrence(startTimeTicks, startInclusive);
if (found == NotFound || found > endTimeTicks) return NotFound;
return found;
}
private long FindPreviousOccurrence(long startTimeTicks, long endTimeTicks, bool startInclusive)
{
var found = FindPreviousOccurrence(startTimeTicks, startInclusive);
if (found == NotFound || found < endTimeTicks) return NotFound;
return found;
}
private long FindOccurrence(long ticks, bool startInclusive)
{
if (!startInclusive) ticks++;
CalendarHelper.FillDateTimeParts(
ticks,
out int startSecond,
out int startMinute,
out int startHour,
out int startDay,
out int startMonth,
out int startYear);
var minMatchedDay = GetFirstSet(_dayOfMonth);
var second = startSecond;
var minute = startMinute;
var hour = startHour;
var day = startDay;
var month = startMonth;
var year = startYear;
if (!GetBit(_second, second) && !Move(_second, ref second)) minute++;
if (!GetBit(_minute, minute) && !Move(_minute, ref minute)) hour++;
if (!GetBit(_hour, hour) && !Move(_hour, ref hour)) day++;
// If NearestWeekday flag is set it's possible forward shift.
if (HasFlag(CronExpressionFlag.NearestWeekday)) day = CronField.DaysOfMonth.First;
if (!GetBit(_dayOfMonth, day) && !Move(_dayOfMonth, ref day)) goto RetryMonth;
if (!GetBit(_month, month)) goto RetryMonth;
Retry:
if (day > GetLastDayOfMonth(year, month)) goto RetryMonth;
if (HasFlag(CronExpressionFlag.DayOfMonthLast)) day = GetLastDayOfMonth(year, month);
var lastCheckedDay = day;
if (HasFlag(CronExpressionFlag.NearestWeekday)) day = CalendarHelper.MoveToNearestWeekDay(year, month, day);
if (IsDayOfWeekMatch(year, month, day))
{
if (CalendarHelper.IsGreaterThan(year, month, day, startYear, startMonth, startDay)) goto RolloverDay;
if (hour > startHour) goto RolloverHour;
if (minute > startMinute) goto RolloverMinute;
goto ReturnResult;
RolloverDay: hour = GetFirstSet(_hour);
RolloverHour: minute = GetFirstSet(_minute);
RolloverMinute: second = GetFirstSet(_second);
ReturnResult:
var found = CalendarHelper.DateTimeToTicks(year, month, day, hour, minute, second);
if (found >= ticks) return found;
}
day = lastCheckedDay;
if (Move(_dayOfMonth, ref day)) goto Retry;
RetryMonth:
if (!Move(_month, ref month))
{
year++;
if (year > DateTime.MaxValue.Year)
{
return NotFound;
}
}
day = minMatchedDay;
goto Retry;
}
private long FindPreviousOccurrence(long ticks, bool startInclusive)
{
if (!startInclusive)
{
if (ticks == DateTime.MinValue.Ticks) return NotFound;
ticks--;
}
CalendarHelper.FillDateTimeParts(
ticks,
out int startSecond,
out int startMinute,
out int startHour,
out int startDay,
out int startMonth,
out int startYear);
if (ticks % TimeSpan.TicksPerSecond != 0) startSecond--;
var maxMatchedDay = HasFlag(CronExpressionFlag.DayOfMonthLast) || HasFlag(CronExpressionFlag.NearestWeekday)
? CronField.DaysOfMonth.Last
: GetLastSet(_dayOfMonth);
var second = startSecond;
var minute = startMinute;
var hour = startHour;
var day = startDay;
var month = startMonth;
var year = startYear;
if (!GetBit(_second, second) && !MoveBack(_second, ref second)) minute--;
if (minute < CronField.Minutes.First || !GetBit(_minute, minute) && !MoveBack(_minute, ref minute)) hour--;
if (hour < CronField.Hours.First || !GetBit(_hour, hour) && !MoveBack(_hour, ref hour)) day--;
if (!GetBit(_month, month)) goto RetryMonth;
Retry:
if (!TryGetPreviousDay(year, month, day, out var lastCheckedDay, out var actualDay)) goto RetryMonth;
if (CalendarHelper.IsGreaterThan(startYear, startMonth, startDay, year, month, actualDay)) goto RolloverDay;
if (hour < startHour) goto RolloverHour;
if (minute < startMinute) goto RolloverMinute;
goto ReturnResult;
RolloverDay: hour = GetLastSet(_hour);
RolloverHour: minute = GetLastSet(_minute);
RolloverMinute: second = GetLastSet(_second);
ReturnResult:
var found = CalendarHelper.DateTimeToTicks(year, month, actualDay, hour, minute, second);
if (found <= ticks) return found;
day = lastCheckedDay;
if (MoveBackDay(ref day)) goto Retry;
RetryMonth:
if (!MoveBack(_month, ref month))
{
year--;
if (year < DateTime.MinValue.Year)
{
return NotFound;
}
}
day = maxMatchedDay;
goto Retry;
}
private static bool Move(ulong fieldBits, ref int fieldValue)
{
if (fieldBits >> ++fieldValue == 0)
{
fieldValue = GetFirstSet(fieldBits);
return false;
}
fieldValue += GetFirstSet(fieldBits >> fieldValue);
return true;
}
private static bool MoveBack(ulong fieldBits, ref int fieldValue)
{
var eligibleBits = GetBitsAtOrBelow(fieldBits, fieldValue - 1);
if (eligibleBits == 0)
{
fieldValue = GetLastSet(fieldBits);
return false;
}
fieldValue = GetLastSet(eligibleBits);
return true;
}
private bool TryGetPreviousDay(int year, int month, int dayLimit, out int day, out int actualDay)
{
var lastDayOfMonth = CalendarHelper.GetDaysInMonth(year, month);
var maxDay = dayLimit > lastDayOfMonth ? lastDayOfMonth : dayLimit;
// When the time-of-day search rolls back past the 1st of the month, dayLimit
// (and therefore maxDay) can drop below the first valid day of month. In that
// case there is no matching day left in this month, so the caller must move to
// the previous month. Without this guard the day-of-month "*" fast path below
// would return actualDay = 0, and DateTimeToTicks(year, month, 0, ...) would
// silently underflow into the last day of the previous month, producing an
// occurrence in a month that is not allowed by the month field.
if (maxDay < CronField.DaysOfMonth.First)
{
day = default;
actualDay = default;
return false;
}
if (HasFlag(CronExpressionFlag.NearestWeekday))
{
day = HasFlag(CronExpressionFlag.DayOfMonthLast)
? GetLastDayOfMonth(year, month)
: GetFirstSet(_dayOfMonth);
actualDay = CalendarHelper.MoveToNearestWeekDay(year, month, day);
return actualDay <= maxDay && IsDayOfWeekMatch(year, month, actualDay);
}
if (HasFlag(CronExpressionFlag.DayOfMonthLast))
{
day = GetLastDayOfMonth(year, month);
actualDay = day;
return actualDay <= maxDay && IsDayOfWeekMatch(year, month, actualDay);
}
if (_dayOfMonth == CronField.DaysOfMonth.AllBits &&
!HasFlag(CronExpressionFlag.DayOfWeekLast) &&
!HasFlag(CronExpressionFlag.NthDayOfWeek))
{
day = maxDay;
actualDay = day;
if (_dayOfWeek == CronField.DaysOfWeek.AllBits)
{
return true;
}
var dayOfWeek = (int)CalendarHelper.GetDayOfWeek(year, month, day);
for (var delta = 0; delta < 7 && day - delta >= CronField.DaysOfMonth.First; delta++)
{
var candidateDayOfWeek = dayOfWeek - delta;
if (candidateDayOfWeek < 0) candidateDayOfWeek += 7;
if (((_dayOfWeek >> candidateDayOfWeek) & 1) == 0) continue;
day -= delta;
actualDay = day;
return true;
}
actualDay = default;
return false;
}
day = maxDay;
if (!GetBit(_dayOfMonth, day) && !MoveBackDay(ref day))
{
actualDay = default;
return false;
}
Retry:
if (day > lastDayOfMonth)
{
if (MoveBackDay(ref day)) goto Retry;
actualDay = default;
return false;
}
actualDay = day;
if (IsDayOfWeekMatch(year, month, actualDay))
{
return true;
}
if (MoveBackDay(ref day))
{
goto Retry;
}
actualDay = default;
return false;
}
private bool MoveBackDay(ref int day)
{
if (HasFlag(CronExpressionFlag.NearestWeekday) || HasFlag(CronExpressionFlag.DayOfMonthLast))
{
return false;
}
if (_dayOfMonth == CronField.DaysOfMonth.AllBits)
{
if (day <= CronField.DaysOfMonth.First)
{
day = CronField.DaysOfMonth.Last;
return false;
}
day--;
return true;
}
return MoveBack(_dayOfMonth, ref day);
}
private bool IsDayMatch(int year, int month, int day)
{
bool dayOfMonthMatch;
if (HasFlag(CronExpressionFlag.NearestWeekday))
{
dayOfMonthMatch = false;
var lastDay = CalendarHelper.GetDaysInMonth(year, month);
if (HasFlag(CronExpressionFlag.DayOfMonthLast))
{
var targetDay = GetLastDayOfMonth(year, month);
dayOfMonthMatch = CalendarHelper.MoveToNearestWeekDay(year, month, targetDay) == day;
}
else
{
for (var targetDay = CronField.DaysOfMonth.First; targetDay <= lastDay; targetDay++)
{
if (!GetBit(_dayOfMonth, targetDay)) continue;
if (CalendarHelper.MoveToNearestWeekDay(year, month, targetDay) != day) continue;
dayOfMonthMatch = true;
break;
}
}
}
else if (HasFlag(CronExpressionFlag.DayOfMonthLast))
{
dayOfMonthMatch = GetLastDayOfMonth(year, month) == day;
}
else
{
dayOfMonthMatch = GetBit(_dayOfMonth, day);
}
return dayOfMonthMatch && IsDayOfWeekMatch(year, month, day);
}
private int GetLastDayOfMonth(int year, int month)
{
return CalendarHelper.GetDaysInMonth(year, month) - _lastMonthOffset;
}
private bool IsDayOfWeekMatch(int year, int month, int day)
{
if (HasFlag(CronExpressionFlag.DayOfWeekLast) && !CalendarHelper.IsLastDayOfWeek(year, month, day) ||
HasFlag(CronExpressionFlag.NthDayOfWeek) && !CalendarHelper.IsNthDayOfWeek(day, _nthDayOfWeek))
{
return false;
}
if (_dayOfWeek == CronField.DaysOfWeek.AllBits) return true;
var dayOfWeek = CalendarHelper.GetDayOfWeek(year, month, day);
return ((_dayOfWeek >> (int)dayOfWeek) & 1) != 0;
}
private static int GetFirstSet(ulong value)
{
// TODO: Add description and source
ulong res = unchecked((ulong)((long)value & -(long)value) * 0x022fdd63cc95386d) >> 58;
return DeBruijnPositions[res];
}
private static int GetLastSet(ulong value)
{
var index = 0;
while ((value >>= 1) != 0)
{
index++;
}
return index;
}
private static int GetLastSetWithin(ulong fieldBits, int maxValue)
{
return GetLastSet(GetBitsAtOrBelow(fieldBits, maxValue));
}
private static ulong GetBitsAtOrBelow(ulong fieldBits, int maxValue)
{
if (maxValue < 0) return 0;
if (maxValue >= 63) return fieldBits;
return fieldBits & ((1UL << (maxValue + 1)) - 1);
}
private bool HasFlag(CronExpressionFlag value)
{
return (_flags & value) != 0;
}
private static StringBuilder AppendFieldValue(StringBuilder expressionBuilder, CronField field, ulong fieldValue)
{
if (field.AllBits == fieldValue) return expressionBuilder.Append('*');
// Unset 7 bit for Day of week field because both 0 and 7 stand for Sunday.
if (field == CronField.DaysOfWeek) fieldValue &= ~(1U << field.Last);
for (var i = GetFirstSet(fieldValue);; i = GetFirstSet(fieldValue >> i << i))
{
expressionBuilder.Append(i);
if (fieldValue >> ++i == 0) break;
expressionBuilder.Append(',');
}
return expressionBuilder;
}
private StringBuilder AppendDayOfMonth(StringBuilder expressionBuilder, uint domValue)
{
if (HasFlag(CronExpressionFlag.DayOfMonthLast))
{
expressionBuilder.Append('L');
if (_lastMonthOffset != 0) expressionBuilder.Append(String.Format(CultureInfo.InvariantCulture, "-{0}", _lastMonthOffset));
}
else
{
AppendFieldValue(expressionBuilder, CronField.DaysOfMonth, (uint)domValue);
}
if (HasFlag(CronExpressionFlag.NearestWeekday)) expressionBuilder.Append('W');
return expressionBuilder;
}
private void AppendDayOfWeek(StringBuilder expressionBuilder, uint dowValue)
{
AppendFieldValue(expressionBuilder, CronField.DaysOfWeek, dowValue);
if (HasFlag(CronExpressionFlag.DayOfWeekLast)) expressionBuilder.Append('L');
else if (HasFlag(CronExpressionFlag.NthDayOfWeek)) expressionBuilder.Append(String.Format(CultureInfo.InvariantCulture, "#{0}", _nthDayOfWeek));
}
[MethodImpl(MethodImplOptions.NoInlining)]
[DoesNotReturn]
private static void ThrowFromShouldBeLessThanToException(string fromName, string toName)
{
throw new ArgumentException($"The value of the {fromName} argument should be less than the value of the {toName} argument.", fromName);
}
[MethodImpl(MethodImplOptions.NoInlining)]
[DoesNotReturn]
private static void ThrowFromShouldBeGreaterThanToException(string fromName, string toName)
{
throw new ArgumentException($"The value of the {fromName} argument should be greater than or equal to the value of the {toName} argument.", fromName);
}
[MethodImpl(MethodImplOptions.NoInlining)]
[DoesNotReturn]
private static void ThrowWrongDateTimeKindException(string paramName)
{
throw new ArgumentException("The supplied DateTime must have the Kind property set to Utc", paramName);
}
[MethodImpl(MethodImplOptions.NoInlining)]
[DoesNotReturn]
private static void ThrowArgumentNullException(string paramName)
{
throw new ArgumentNullException(paramName);
}
private static bool GetBit(ulong value, int index)
{
return (value & (1UL << index)) != 0;
}
}
}