| | | 1 | | using System.Globalization; |
| | | 2 | | |
| | | 3 | | namespace AsyncResponse; |
| | | 4 | | |
| | | 5 | | /// <summary> |
| | | 6 | | /// A parsed five-field cron expression (<c>minute hour day-of-month month day-of-week</c>) with an |
| | | 7 | | /// optional time zone, used by <c>WithScheduledFlow</c> to start flows on a schedule. |
| | | 8 | | /// <para> |
| | | 9 | | /// Supported syntax per field: <c>*</c>, single values, lists (<c>1,15</c>), ranges (<c>1-5</c>, |
| | | 10 | | /// wrap-around <c>22-2</c> included), steps (<c>*/15</c>, <c>10-40/5</c>, <c>8/2</c>), and names |
| | | 11 | | /// (<c>JAN…DEC</c>, <c>SUN…SAT</c>, case-insensitive). <c>?</c> is accepted as <c>*</c> in the two |
| | | 12 | | /// day fields. Day-of-month and day-of-week combine with classic Vixie-cron semantics: when both |
| | | 13 | | /// fields are explicitly restricted (neither starts with <c>*</c>), a date matches if <em>either</em> |
| | | 14 | | /// matches; otherwise both masks must match — a star-step field such as <c>*/2</c> stays out of the |
| | | 15 | | /// either/or rule (exactly as Vixie's <c>DOM_STAR</c>/<c>DOW_STAR</c> flags keep it) while its step |
| | | 16 | | /// mask still applies. Day-of-week accepts <c>0</c> and <c>7</c> as Sunday. |
| | | 17 | | /// </para> |
| | | 18 | | /// <para> |
| | | 19 | | /// Occurrences are computed minute-aligned in the schedule's time zone (default UTC) and returned |
| | | 20 | | /// as UTC instants. Around daylight-saving transitions: a local occurrence that does not exist |
| | | 21 | | /// (spring-forward gap) fires at the moment the clock jumps past it; an occurrence in a repeated |
| | | 22 | | /// hour (fall-back) fires on the first (earlier-offset) pass only. |
| | | 23 | | /// </para> |
| | | 24 | | /// </summary> |
| | | 25 | | public sealed class CronSchedule |
| | | 26 | | { |
| | | 27 | | private readonly ulong _minutes; // bits 0..59 |
| | | 28 | | private readonly uint _hours; // bits 0..23 |
| | | 29 | | private readonly uint _daysOfMonth; // bits 1..31 |
| | | 30 | | private readonly ushort _months; // bits 1..12 |
| | | 31 | | private readonly byte _daysOfWeek; // bits 0..6 (Sunday = 0) |
| | | 32 | | private readonly bool _dayOfMonthRestricted; |
| | | 33 | | private readonly bool _dayOfWeekRestricted; |
| | | 34 | | |
| | | 35 | | /// <summary>The original expression text.</summary> |
| | 0 | 36 | | public string Expression { get; } |
| | | 37 | | |
| | | 38 | | /// <summary>The time zone the schedule is evaluated in (default UTC).</summary> |
| | 69452 | 39 | | public TimeZoneInfo TimeZone { get; } |
| | | 40 | | |
| | 226 | 41 | | private CronSchedule( |
| | 226 | 42 | | string expression, |
| | 226 | 43 | | TimeZoneInfo timeZone, |
| | 226 | 44 | | ulong minutes, |
| | 226 | 45 | | uint hours, |
| | 226 | 46 | | uint daysOfMonth, |
| | 226 | 47 | | ushort months, |
| | 226 | 48 | | byte daysOfWeek, |
| | 226 | 49 | | bool dayOfMonthRestricted, |
| | 226 | 50 | | bool dayOfWeekRestricted) |
| | | 51 | | { |
| | 226 | 52 | | Expression = expression; |
| | 226 | 53 | | TimeZone = timeZone; |
| | 226 | 54 | | _minutes = minutes; |
| | 226 | 55 | | _hours = hours; |
| | 226 | 56 | | _daysOfMonth = daysOfMonth; |
| | 226 | 57 | | _months = months; |
| | 226 | 58 | | _daysOfWeek = daysOfWeek; |
| | 226 | 59 | | _dayOfMonthRestricted = dayOfMonthRestricted; |
| | 226 | 60 | | _dayOfWeekRestricted = dayOfWeekRestricted; |
| | 226 | 61 | | } |
| | | 62 | | |
| | | 63 | | /// <summary>Parses a five-field cron expression; throws <see cref="FormatException"/> with the offending field on i |
| | | 64 | | public static CronSchedule Parse(string expression, TimeZoneInfo? timeZone = null) |
| | | 65 | | { |
| | 256 | 66 | | ArgumentException.ThrowIfNullOrWhiteSpace(expression); |
| | | 67 | | |
| | 254 | 68 | | var fields = expression.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); |
| | 254 | 69 | | if (fields.Length != 5) |
| | 4 | 70 | | throw new FormatException($"Cron expression '{expression}' must have exactly 5 fields (minute hour day-of-mo |
| | | 71 | | |
| | 250 | 72 | | var minutes = ParseField(expression, fields[0], 0, 59, names: null, allowQuestionMark: false); |
| | 238 | 73 | | var hours = ParseField(expression, fields[1], 0, 23, names: null, allowQuestionMark: false); |
| | 236 | 74 | | var daysOfMonth = ParseField(expression, fields[2], 1, 31, names: null, allowQuestionMark: true); |
| | 232 | 75 | | var months = ParseField(expression, fields[3], 1, 12, MonthNames, allowQuestionMark: false); |
| | | 76 | | // wrapCycle 7, not the 8-slot 0..7 span: slots 0 and 7 are both Sunday, so a stride that |
| | | 77 | | // wraps past Saturday must fold back by one real week or it lands a day early. |
| | 228 | 78 | | var daysOfWeek = ParseField(expression, fields[4], 0, 7, DayNames, allowQuestionMark: true, wrapCycle: 7); |
| | | 79 | | |
| | | 80 | | // 7 is Sunday too; fold it onto bit 0 so matching only ever looks at bits 0..6. |
| | 226 | 81 | | if ((daysOfWeek & (1UL << 7)) != 0) |
| | 186 | 82 | | daysOfWeek = (daysOfWeek & ~(1UL << 7)) | 1UL; |
| | | 83 | | |
| | 226 | 84 | | var dayOfMonthRestricted = !IsStarShaped(fields[2]); |
| | 226 | 85 | | var dayOfWeekRestricted = !IsStarShaped(fields[4]); |
| | | 86 | | |
| | 226 | 87 | | return new CronSchedule( |
| | 226 | 88 | | expression, |
| | 226 | 89 | | timeZone ?? TimeZoneInfo.Utc, |
| | 226 | 90 | | minutes, |
| | 226 | 91 | | (uint)hours, |
| | 226 | 92 | | (uint)daysOfMonth, |
| | 226 | 93 | | (ushort)months, |
| | 226 | 94 | | (byte)daysOfWeek, |
| | 226 | 95 | | dayOfMonthRestricted, |
| | 226 | 96 | | dayOfWeekRestricted); |
| | | 97 | | |
| | | 98 | | // Vixie sets its DOM_STAR/DOW_STAR flags on any day field whose text starts with '*' |
| | | 99 | | // ("*", "*/2"): such a field stays OUT of the either/or rule below, but its step mask |
| | | 100 | | // still applies — DayMatches ANDs the two masks unless BOTH fields are explicitly |
| | | 101 | | // restricted. That keeps "0 0 */2 * *" at every other day (not every day) without turning |
| | | 102 | | // "0 0 */2 * FRI" into odd-days-OR-Fridays (crontab(5): odd Fridays only). |
| | | 103 | | // '?' is documented as a synonym for '*' in the day fields, so it carries the flag on the |
| | | 104 | | // same terms — including when stepped: "?/2" must behave exactly like "*/2", not fall |
| | | 105 | | // through to the explicitly-restricted branch and silently flip the dom/dow rule to OR. |
| | 452 | 106 | | static bool IsStarShaped(string field) => field.StartsWith('*') || field.StartsWith('?'); |
| | | 107 | | } |
| | | 108 | | |
| | | 109 | | /// <summary> |
| | | 110 | | /// Returns the first occurrence strictly after <paramref name="afterUtc"/>, as a UTC instant, |
| | | 111 | | /// or <c>null</c> when the expression can never fire (an impossible date such as "Feb 30"). |
| | | 112 | | /// </summary> |
| | | 113 | | public DateTimeOffset? GetNextOccurrence(DateTimeOffset afterUtc) |
| | | 114 | | { |
| | | 115 | | // Work minute-aligned in schedule-local time: advance to the next whole minute after |
| | | 116 | | // `afterUtc`, then scan forward. The scan is bounded, not clever — correctness and DST |
| | | 117 | | // honesty beat arithmetic shortcuts at one iteration per minute only in the worst field. |
| | 17366 | 118 | | var local = TimeZoneInfo.ConvertTime(afterUtc, TimeZone); |
| | 17366 | 119 | | var minuteAligned = new DateTime(local.Year, local.Month, local.Day, local.Hour, local.Minute, 0, DateTimeKind.U |
| | 17366 | 120 | | if (minuteAligned >= LastRepresentableMinute) |
| | 2 | 121 | | return null; // No whole minute after this one exists to schedule. |
| | | 122 | | |
| | 17364 | 123 | | var candidate = minuteAligned.AddMinutes(1); |
| | | 124 | | |
| | | 125 | | // Horizon: 400 Gregorian years — a full calendar cycle (146 097 days, exactly 20 871 |
| | | 126 | | // weeks), so any (month, day, weekday) combination the calendar ever produces occurs |
| | | 127 | | // within the next 400 years of ANY start date. A miss is therefore a completeness proof |
| | | 128 | | // of unsatisfiability, not a heuristic: "0 0 29 2 */7" (Feb 29 on a Sunday, gaps of up to |
| | | 129 | | // 40 years around skipped century leap days) resolves; "Feb 30" is proven impossible. |
| | | 130 | | // The scan stays cheap because misses skip by month/day (an impossible date walks a few |
| | | 131 | | // dozen candidates per year, not half a million minutes). The horizon saturates at |
| | | 132 | | // DateTime.MaxValue rather than being pulled BACK to a fixed cap: a cap below the |
| | | 133 | | // candidate would end the loop before its first iteration and report even "* * * * *" |
| | | 134 | | // as unsatisfiable. |
| | 17364 | 135 | | var horizon = candidate < HorizonCap ? candidate.AddYears(400) : DateTime.MaxValue; |
| | | 136 | | |
| | | 137 | | try |
| | | 138 | | { |
| | 17364 | 139 | | return Scan(candidate, horizon, afterUtc); |
| | | 140 | | } |
| | 2 | 141 | | catch (ArgumentOutOfRangeException) |
| | | 142 | | { |
| | | 143 | | // The scan walked off the end of the representable calendar: an advance past |
| | | 144 | | // DateTime.MaxValue, or a matched wall time whose UTC instant (wall minus a positive |
| | | 145 | | // offset) does not exist as a DateTimeOffset. Either way there is no further |
| | | 146 | | // occurrence to return, which is exactly what null means. Nothing else in the scan |
| | | 147 | | // can raise this — the matchers are bit tests and the time-zone lookups take only |
| | | 148 | | // in-range values. |
| | 2 | 149 | | return null; |
| | | 150 | | } |
| | 17364 | 151 | | } |
| | | 152 | | |
| | | 153 | | private DateTimeOffset? Scan(DateTime candidate, DateTime horizon, DateTimeOffset afterUtc) |
| | | 154 | | { |
| | 718095 | 155 | | while (candidate < horizon) |
| | | 156 | | { |
| | 718055 | 157 | | if (!MonthMatches(candidate.Month)) |
| | | 158 | | { |
| | | 159 | | // Jump to the first minute of the next month; day scanning below stays in-month. |
| | 177550 | 160 | | candidate = new DateTime(candidate.Year, candidate.Month, 1, 0, 0, 0, DateTimeKind.Unspecified).AddMonth |
| | 177548 | 161 | | continue; |
| | | 162 | | } |
| | | 163 | | |
| | 540505 | 164 | | if (!DayMatches(candidate)) |
| | | 165 | | { |
| | 462874 | 166 | | candidate = candidate.Date.AddDays(1); |
| | 462874 | 167 | | continue; |
| | | 168 | | } |
| | | 169 | | |
| | 77631 | 170 | | if (!HourMatches(candidate.Hour)) |
| | | 171 | | { |
| | 12440 | 172 | | candidate = new DateTime(candidate.Year, candidate.Month, candidate.Day, candidate.Hour, 0, 0, DateTimeK |
| | 12440 | 173 | | continue; |
| | | 174 | | } |
| | | 175 | | |
| | 65191 | 176 | | if (!MinuteMatches(candidate.Minute)) |
| | | 177 | | { |
| | 47869 | 178 | | candidate = candidate.AddMinutes(1); |
| | 47869 | 179 | | continue; |
| | | 180 | | } |
| | | 181 | | |
| | | 182 | | // A schedule-local match. Map it onto the UTC timeline honoring DST: |
| | 17322 | 183 | | if (TimeZone.IsInvalidTime(candidate)) |
| | | 184 | | { |
| | | 185 | | // Spring-forward gap: the wall-clock time never happens, so the job fires at the |
| | | 186 | | // gap's END — the exact transition instant, the moment the clock jumps past the |
| | | 187 | | // scheduled time (what cron daemons do for jobs the jump skips). That instant is |
| | | 188 | | // the gap's FIRST wall minute interpreted with the offset in force just before |
| | | 189 | | // the gap. Interpreting the candidate minute itself with the pre-gap offset would |
| | | 190 | | // land PAST the transition (02:30 in a 02:00→03:00 jump would fire at 03:30, not |
| | | 191 | | // 03:00) — a later instant that also breaks next-occurrence ordering: it would be |
| | | 192 | | // reported as the future occurrence and then skipped by a re-query inside the |
| | | 193 | | // half-open window. Multiple scheduled minutes inside one gap all collapse onto |
| | | 194 | | // the same transition instant, and the strictly-after filter fires it once. |
| | 6 | 195 | | var gapStart = candidate; |
| | 126 | 196 | | while (TimeZone.IsInvalidTime(gapStart.AddMinutes(-1))) |
| | 120 | 197 | | gapStart = gapStart.AddMinutes(-1); |
| | | 198 | | |
| | 6 | 199 | | var preTransitionOffset = TimeZone.GetUtcOffset(gapStart.AddMinutes(-1)); |
| | 6 | 200 | | var instant = new DateTimeOffset(gapStart, preTransitionOffset).ToUniversalTime(); |
| | 6 | 201 | | if (instant > afterUtc) |
| | 6 | 202 | | return instant; |
| | | 203 | | |
| | 0 | 204 | | candidate = candidate.AddMinutes(1); |
| | 0 | 205 | | continue; |
| | | 206 | | } |
| | | 207 | | |
| | | 208 | | DateTimeOffset occurrence; |
| | 17316 | 209 | | if (TimeZone.IsAmbiguousTime(candidate)) |
| | | 210 | | { |
| | | 211 | | // Fall-back repeat: fire on the FIRST (earlier-offset, typically DST) pass only. |
| | 2 | 212 | | var offsets = TimeZone.GetAmbiguousTimeOffsets(candidate); |
| | 2 | 213 | | var first = offsets[0]; |
| | 12 | 214 | | foreach (var offset in offsets) |
| | | 215 | | { |
| | 4 | 216 | | if (offset > first) |
| | 2 | 217 | | first = offset; // larger UTC offset = earlier UTC instant |
| | | 218 | | } |
| | | 219 | | |
| | 2 | 220 | | occurrence = new DateTimeOffset(candidate, first).ToUniversalTime(); |
| | | 221 | | } |
| | | 222 | | else |
| | | 223 | | { |
| | 17314 | 224 | | occurrence = new DateTimeOffset(candidate, TimeZone.GetUtcOffset(candidate)).ToUniversalTime(); |
| | | 225 | | } |
| | | 226 | | |
| | 17316 | 227 | | if (occurrence > afterUtc) |
| | 17316 | 228 | | return occurrence; |
| | | 229 | | |
| | 0 | 230 | | candidate = candidate.AddMinutes(1); |
| | | 231 | | } |
| | | 232 | | |
| | 40 | 233 | | return null; |
| | | 234 | | } |
| | | 235 | | |
| | 65191 | 236 | | private bool MinuteMatches(int minute) => (_minutes & (1UL << minute)) != 0; |
| | 77631 | 237 | | private bool HourMatches(int hour) => (_hours & (1U << hour)) != 0; |
| | 718055 | 238 | | private bool MonthMatches(int month) => (_months & (1 << month)) != 0; |
| | | 239 | | |
| | | 240 | | private bool DayMatches(DateTime date) |
| | | 241 | | { |
| | 540505 | 242 | | var dayOfMonth = (_daysOfMonth & (1U << date.Day)) != 0; |
| | 540505 | 243 | | var dayOfWeek = (_daysOfWeek & (1 << (int)date.DayOfWeek)) != 0; |
| | | 244 | | |
| | | 245 | | // Vixie-cron dom/dow rule: only when BOTH fields are explicitly restricted does either |
| | | 246 | | // match suffice; when either field is star-shaped ("*", "*/N", "?") both masks must match |
| | | 247 | | // (a plain "*" mask is full, so the other field decides alone — the classic behavior). |
| | 540505 | 248 | | return _dayOfMonthRestricted && _dayOfWeekRestricted |
| | 540505 | 249 | | ? dayOfMonth || dayOfWeek |
| | 540505 | 250 | | : dayOfMonth && dayOfWeek; |
| | | 251 | | } |
| | | 252 | | |
| | 2 | 253 | | private static readonly string[] MonthNames = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", |
| | 2 | 254 | | private static readonly string[] DayNames = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"]; |
| | | 255 | | |
| | | 256 | | // Latest start for which "candidate + 400 years" stays inside DateTime; past it the horizon |
| | | 257 | | // saturates at DateTime.MaxValue instead. |
| | 2 | 258 | | private static readonly DateTime HorizonCap = DateTime.MaxValue.AddYears(-400); |
| | | 259 | | |
| | | 260 | | // The last whole minute DateTime can represent; a start at or past it has no next minute. |
| | 2 | 261 | | private static readonly DateTime LastRepresentableMinute = |
| | 2 | 262 | | new(9999, 12, 31, 23, 59, 0, DateTimeKind.Unspecified); |
| | | 263 | | |
| | | 264 | | private static ulong ParseField(string expression, string field, int min, int max, string[]? names, bool allowQuesti |
| | | 265 | | { |
| | 1184 | 266 | | var cycle = wrapCycle ?? (max - min + 1); |
| | 1184 | 267 | | if (field == "*" || (allowQuestionMark && field == "?")) |
| | 686 | 268 | | return RangeMask(min, max, min, max, 1, cycle); |
| | | 269 | | |
| | 498 | 270 | | ulong mask = 0; |
| | 1984 | 271 | | foreach (var part in field.Split(',')) |
| | | 272 | | { |
| | 506 | 273 | | if (part.Length == 0) |
| | 0 | 274 | | throw Invalid(expression, field, "empty list entry"); |
| | | 275 | | |
| | 506 | 276 | | var stepSplit = part.Split('/'); |
| | 506 | 277 | | if (stepSplit.Length > 2) |
| | 2 | 278 | | throw Invalid(expression, field, $"'{part}' has more than one '/'"); |
| | | 279 | | |
| | 504 | 280 | | var step = 1; |
| | 504 | 281 | | if (stepSplit.Length == 2) |
| | | 282 | | { |
| | 84 | 283 | | if (!int.TryParse(stepSplit[1], NumberStyles.None, CultureInfo.InvariantCulture, out step) || step <= 0) |
| | 2 | 284 | | throw Invalid(expression, field, $"step '{stepSplit[1]}' must be a positive integer"); |
| | | 285 | | } |
| | | 286 | | |
| | 502 | 287 | | var rangePart = stepSplit[0]; |
| | | 288 | | int low, high; |
| | 502 | 289 | | if (rangePart == "*" || (allowQuestionMark && rangePart == "?")) |
| | | 290 | | { |
| | 42 | 291 | | low = min; |
| | 42 | 292 | | high = max; |
| | | 293 | | } |
| | | 294 | | else |
| | | 295 | | { |
| | 460 | 296 | | var rangeSplit = rangePart.Split('-'); |
| | 460 | 297 | | if (rangeSplit.Length > 2) |
| | 2 | 298 | | throw Invalid(expression, field, $"'{rangePart}' has more than one '-'"); |
| | | 299 | | |
| | 458 | 300 | | low = ParseValue(expression, field, rangeSplit[0], min, max, names); |
| | 440 | 301 | | if (rangeSplit.Length == 2) |
| | | 302 | | { |
| | 52 | 303 | | high = ParseValue(expression, field, rangeSplit[1], min, max, names); |
| | | 304 | | } |
| | 388 | 305 | | else if (stepSplit.Length == 2) |
| | | 306 | | { |
| | | 307 | | // Vixie extension "N/step": start at N, run to the field maximum. |
| | 8 | 308 | | high = max; |
| | | 309 | | } |
| | | 310 | | else |
| | | 311 | | { |
| | 380 | 312 | | high = low; |
| | | 313 | | } |
| | | 314 | | } |
| | | 315 | | |
| | 482 | 316 | | mask |= RangeMask(min, max, low, high, step, cycle); |
| | | 317 | | } |
| | | 318 | | |
| | 474 | 319 | | return mask; |
| | | 320 | | } |
| | | 321 | | |
| | | 322 | | private static int ParseValue(string expression, string field, string token, int min, int max, string[]? names) |
| | | 323 | | { |
| | 510 | 324 | | if (names is not null) |
| | | 325 | | { |
| | 1588 | 326 | | for (var index = 0; index < names.Length; index++) |
| | | 327 | | { |
| | 746 | 328 | | if (string.Equals(token, names[index], StringComparison.OrdinalIgnoreCase)) |
| | | 329 | | { |
| | | 330 | | // Month names are 1-based (JAN=1); day names 0-based (SUN=0). |
| | 62 | 331 | | return names == MonthNames ? index + 1 : index; |
| | | 332 | | } |
| | | 333 | | } |
| | | 334 | | } |
| | | 335 | | |
| | 448 | 336 | | if (!int.TryParse(token, NumberStyles.None, CultureInfo.InvariantCulture, out var value)) |
| | 4 | 337 | | throw Invalid(expression, field, $"'{token}' is not a number{(names is null ? "" : " or name")}"); |
| | 444 | 338 | | if (value < min || value > max) |
| | 14 | 339 | | throw Invalid(expression, field, $"'{token}' is outside {min}-{max}"); |
| | 430 | 340 | | return value; |
| | | 341 | | } |
| | | 342 | | |
| | | 343 | | private static ulong RangeMask(int min, int max, int low, int high, int step, int cycle) |
| | | 344 | | { |
| | | 345 | | // The accumulators are long: with an int, a step near int.MaxValue overflows `value += step` |
| | | 346 | | // to a negative, and the (six-bit-masked) shift then sets phantom low bits — "1/2147483647" |
| | | 347 | | // would silently gain minute 0. In long arithmetic an oversized step simply runs past `high` |
| | | 348 | | // after the first value, which is exactly Vixie's semantics for steps beyond the field span. |
| | 1168 | 349 | | ulong mask = 0; |
| | 1168 | 350 | | if (low <= high) |
| | | 351 | | { |
| | 32844 | 352 | | for (long value = low; value <= high; value += step) |
| | 15292 | 353 | | mask |= 1UL << (int)value; |
| | 1130 | 354 | | return mask; |
| | | 355 | | } |
| | | 356 | | |
| | | 357 | | // Wrap-around range (e.g. hours 22-2): low..max then min..high, stepping continuously. |
| | 38 | 358 | | long position = low; |
| | 96 | 359 | | while (position <= max) |
| | | 360 | | { |
| | 58 | 361 | | mask |= 1UL << (int)position; |
| | 58 | 362 | | position += step; |
| | | 363 | | } |
| | | 364 | | |
| | | 365 | | // Continue the stride into the wrapped segment so 50-10/4 keeps its cadence across the |
| | | 366 | | // wrap: fold back by one full cycle of the field's VALUES. For every field but |
| | | 367 | | // day-of-week that equals the slot span; day-of-week has 8 slots for 7 values (0 and 7 |
| | | 368 | | // are both Sunday), and folding by the span would credit the duplicate slot as a real |
| | | 369 | | // day — "SAT-MON/2" fired Saturday+Sunday instead of Saturday+Monday. |
| | 38 | 370 | | position -= cycle; |
| | 80 | 371 | | while (position <= high) |
| | | 372 | | { |
| | 42 | 373 | | mask |= 1UL << (int)position; |
| | 42 | 374 | | position += step; |
| | | 375 | | } |
| | | 376 | | |
| | 38 | 377 | | return mask; |
| | | 378 | | } |
| | | 379 | | |
| | | 380 | | private static FormatException Invalid(string expression, string field, string reason) |
| | 24 | 381 | | => new($"Cron expression '{expression}' has an invalid field '{field}': {reason}."); |
| | | 382 | | } |