From 7cf4d71038a0a76b6a19d2c6244b633a6014ecb6 Mon Sep 17 00:00:00 2001 From: Justin Tout <3639630+justintout@users.noreply.github.com> Date: Sat, 3 Oct 2026 12:44:20 -0400 Subject: [PATCH 1/5] Check for @reboot before validity so its error message is reachable Fixes #15 --- lib/src/cronparse.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/src/cronparse.dart b/lib/src/cronparse.dart index fc8550f..d441f64 100644 --- a/lib/src/cronparse.dart +++ b/lib/src/cronparse.dart @@ -4,12 +4,12 @@ import './validators.dart'; /// calculate [DateTime]s and [Duration]s from a given cron expression. class Cron { Cron(this.expr) { - if (!isValid(expr)) { - throw ArgumentError('invalid cron expression: "$expr"'); - } if (expr == "@reboot") { throw ArgumentError('nickname expression "@reboot" is not supported'); } + if (!isValid(expr)) { + throw ArgumentError('invalid cron expression: "$expr"'); + } if (expr.startsWith("@")) { switch (expr) { From 60109d8effa53ea6a607ef049607723fec3869d0 Mon Sep 17 00:00:00 2001 From: Justin Tout <3639630+justintout@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:37:22 -0400 Subject: [PATCH 2/5] Map @midnight to 0 0 * * *, matching @daily Fixes #14 --- lib/src/cronparse.dart | 2 +- test/cronparse_test.dart | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/lib/src/cronparse.dart b/lib/src/cronparse.dart index d441f64..d53a471 100644 --- a/lib/src/cronparse.dart +++ b/lib/src/cronparse.dart @@ -32,7 +32,7 @@ class Cron { _parsedExpr = "0 * * * *"; break; case "@midnight": - _parsedExpr = "0 23 * * *"; + _parsedExpr = "0 0 * * *"; break; default: _parsedExpr = expr; diff --git a/test/cronparse_test.dart b/test/cronparse_test.dart index 2f4f6b3..091ba9e 100644 --- a/test/cronparse_test.dart +++ b/test/cronparse_test.dart @@ -345,6 +345,25 @@ void main() { // 2021-11-15 is a Monday, not the 13th -> matches neither. expect(cron.matches(DateTime.parse('2021-11-15 00:00:00')), isFalse); }); + + // https://github.com/justintout/cronparse/issues/14 + test("#14: nicknames agree with their crontab(5) expansions", () { + final current = DateTime.parse('2021-07-11 13:00:01'); + const expansions = { + '@yearly': '0 0 1 1 *', + '@annually': '0 0 1 1 *', + '@monthly': '0 0 1 * *', + '@weekly': '0 0 * * 0', + '@daily': '0 0 * * *', + '@midnight': '0 0 * * *', + '@hourly': '0 * * * *', + }; + expansions.forEach((nickname, expr) { + expect(Cron(nickname).nextRelativeTo(current), + Cron(expr).nextRelativeTo(current), + reason: nickname); + }); + }); }); }); } From c03b93633f16abb8d369c34f1179c4155f2a95b1 Mon Sep 17 00:00:00 2001 From: Justin Tout <3639630+justintout@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:37:23 -0400 Subject: [PATCH 3/5] Reject a day of month that never occurs in the selected months An expression like "0 0 30 2 *" made nextRelativeTo and previousRelativeTo search minute by minute forever. The constructor now throws an ArgumentError when day-of-week is unrestricted and no selected month contains a selected day. Fixes #13 --- lib/src/cronparse.dart | 20 ++++++++++++++++++++ test/cronparse_test.dart | 19 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/lib/src/cronparse.dart b/lib/src/cronparse.dart index d53a471..7442363 100644 --- a/lib/src/cronparse.dart +++ b/lib/src/cronparse.dart @@ -79,6 +79,26 @@ class Cron { .replaceAll('fri', '5') .replaceAll('sat', '6') .replaceAll('sun', '7'); + + // With day-of-week unrestricted, day-of-month alone selects days, and it + // can name a day that never occurs in the selected months (Feb 30). The + // minute-by-minute search would then never terminate. + if (_dayOfWeekField == '*' && !_hasPossibleDay()) { + throw ArgumentError( + 'day of month never occurs in the selected months: "$expr"'); + } + } + + bool _hasPossibleDay() { + for (var month = 1; month <= 12; month++) { + if (!_fieldMatches(_monthField, month)) continue; + // 2000 is a leap year, so February counts its 29th. + final days = DateTime(2000, month + 1, 0).day; + for (var day = 1; day <= days; day++) { + if (_fieldMatches(_dayOfMonthField, day)) return true; + } + } + return false; } final String expr; diff --git a/test/cronparse_test.dart b/test/cronparse_test.dart index 091ba9e..b0e05e0 100644 --- a/test/cronparse_test.dart +++ b/test/cronparse_test.dart @@ -364,6 +364,25 @@ void main() { reason: nickname); }); }); + + // https://github.com/justintout/cronparse/issues/13 + // Searching for a day that never occurs used to loop forever. + test("#13: rejects a day of month that never occurs in its months", () { + for (final expr in [ + '0 0 30 2 *', + '0 0 31 2 *', + '0 0 31 4 *', + '0 0 31 2,4,6 *', + '0 0 31 apr *', + ]) { + expect(() => Cron(expr), throwsArgumentError, reason: expr); + } + final current = DateTime.parse('2021-07-11 13:00:01'); + expect(Cron('0 0 29 2 *').nextRelativeTo(current), + DateTime.parse('2024-02-29 00:00:00')); + expect(Cron('0 0 30 2 MON').nextRelativeTo(current), + DateTime.parse('2022-02-07 00:00:00')); + }); }); }); } From 51eaae343dc1c90526d7fe87a16a7edfacf2fa12 Mon Sep 17 00:00:00 2001 From: Justin Tout <3639630+justintout@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:50:00 -0400 Subject: [PATCH 4/5] Reject expressions that can never match in isValid isValid now rejects reversed ranges, zero steps, and a day of month that never occurs in the selected months. Previously the first two passed validation and made nextRelativeTo loop forever, and the third was only rejected by the Cron constructor. Field normalization and matching move to fields.dart so isValid and Cron share them. --- README.md | 2 +- lib/src/cronparse.dart | 108 +++++----------------------------------- lib/src/fields.dart | 73 +++++++++++++++++++++++++++ lib/src/validators.dart | 42 +++++++++++++++- test/fixtures.dart | 10 ++++ 5 files changed, 136 insertions(+), 99 deletions(-) create mode 100644 lib/src/fields.dart diff --git a/README.md b/README.md index aa344c4..05ec43d 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ Parse Unix cron expressions and calculate things like: -- whether the expression is valid, as a `bool` +- whether the expression is valid, as a `bool`. an expression that can never match, such as `0 0 30 2 *` or the reversed range `30-10 * * * *`, is invalid - the next time the expression will run, as a `DateTime` - the last time the expression would have run, as a `DateTime` - the duration until the next time the expression will run, as a `Duration` diff --git a/lib/src/cronparse.dart b/lib/src/cronparse.dart index 7442363..0c8e578 100644 --- a/lib/src/cronparse.dart +++ b/lib/src/cronparse.dart @@ -1,3 +1,4 @@ +import './fields.dart'; import './validators.dart'; /// [Cron] is an object exposing various methods to @@ -41,64 +42,12 @@ class Cron { _parsedExpr = expr; } - final field = _parsedExpr.split(" "); - assert(field.length == 5); - - _minuteField = - field[0].contains("/") ? field[0].replaceFirst("*", "0-59") : field[0]; - _hourField = - field[1].contains("/") ? field[1].replaceFirst("*", "0-23") : field[1]; - _dayOfMonthField = - field[2].contains("/") ? field[2].replaceFirst("*", "1-31") : field[2]; - - final monthField = - field[3].contains("/") ? field[3].replaceFirst("*", "1-12") : field[3]; - _monthField = monthField - .toLowerCase() - .replaceAll('jan', '1') - .replaceAll('feb', '2') - .replaceAll('mar', '3') - .replaceAll('apr', '4') - .replaceAll('may', '5') - .replaceAll('jun', '6') - .replaceAll('jul', '7') - .replaceAll('aug', '8') - .replaceAll('sep', '9') - .replaceAll('oct', '10') - .replaceAll('nov', '11') - .replaceAll('dec', '12'); - - final dayOfWeekField = - field[4].contains("/") ? field[4].replaceFirst("*", "0-7") : field[4]; - _dayOfWeekField = dayOfWeekField - .toLowerCase() - .replaceAll('mon', '1') - .replaceAll('tue', '2') - .replaceAll('wed', '3') - .replaceAll('thu', '4') - .replaceAll('fri', '5') - .replaceAll('sat', '6') - .replaceAll('sun', '7'); - - // With day-of-week unrestricted, day-of-month alone selects days, and it - // can name a day that never occurs in the selected months (Feb 30). The - // minute-by-minute search would then never terminate. - if (_dayOfWeekField == '*' && !_hasPossibleDay()) { - throw ArgumentError( - 'day of month never occurs in the selected months: "$expr"'); - } - } - - bool _hasPossibleDay() { - for (var month = 1; month <= 12; month++) { - if (!_fieldMatches(_monthField, month)) continue; - // 2000 is a leap year, so February counts its 29th. - final days = DateTime(2000, month + 1, 0).day; - for (var day = 1; day <= days; day++) { - if (_fieldMatches(_dayOfMonthField, day)) return true; - } - } - return false; + final field = normalizeFields(_parsedExpr.split(" ")); + _minuteField = field[0]; + _hourField = field[1]; + _dayOfMonthField = field[2]; + _monthField = field[3]; + _dayOfWeekField = field[4]; } final String expr; @@ -128,25 +77,25 @@ class Cron { /// `minuteMatches` returns true if the minute field of the expression /// matches the minute of the given time bool minuteMatches(DateTime time) { - return _fieldMatches(_minuteField, time.minute); + return fieldMatches(_minuteField, time.minute); } /// `hourMatches` returns true if the hour field of the expression /// matches the hour of the given time bool hourMatches(DateTime time) { - return _fieldMatches(_hourField, time.hour); + return fieldMatches(_hourField, time.hour); } /// `dayOfMonthMatches` returns true if the day of month field of the /// expression matches the day of month of the given time bool dayOfMonthMatches(DateTime time) { - return _fieldMatches(_dayOfMonthField, time.day); + return fieldMatches(_dayOfMonthField, time.day); } /// `monthMatches` returns true if the month field of the expression /// matches the month of the given time bool monthMatches(DateTime time) { - return _fieldMatches(_monthField, time.month); + return fieldMatches(_monthField, time.month); } /// `dayOfWeekMatches` returns true if the day of week field of the expression @@ -155,40 +104,7 @@ class Cron { /// Both 0 and 7 represent Sunday. [DateTime.weekday] uses 7 for Sunday, so /// a cron value of 0 is treated as matching a weekday of 7. bool dayOfWeekMatches(DateTime time) { - return _fieldMatches(_dayOfWeekField, time.weekday, sundayAlias: true); - } - - /// `_fieldMatches` evaluates a single cron field against an integer [value]. - /// - /// It handles the shared grammar for every field: asterisk, skips - /// (`*/n`, `a-b/n`), exact values, ranges (`a-b`), and comma-separated sets - /// of any of those. When [sundayAlias] is set (day-of-week), a field value of - /// 0 also matches a [value] of 7 so that both 0 and 7 mean Sunday. - bool _fieldMatches(String field, int value, {bool sundayAlias = false}) { - if (field == '*') return true; - - bool hits(int i) => i == value || (sundayAlias && i == 0 && value == 7); - - for (final part in field.split(',')) { - if (part.contains('/')) { - final s = part.split('/'); - final skips = int.parse(s[1]); - final bounds = s[0].split('-').map(int.parse).toList(); - for (var i = bounds[0]; i <= bounds[1]; i += skips) { - if (hits(i)) return true; - } - continue; - } - if (part.contains('-')) { - final bounds = part.split('-').map(int.parse).toList(); - for (var i = bounds[0]; i <= bounds[1]; i++) { - if (hits(i)) return true; - } - continue; - } - if (hits(int.parse(part))) return true; - } - return false; + return fieldMatches(_dayOfWeekField, time.weekday, sundayAlias: true); } /// `next` calculates the next [DateTime] diff --git a/lib/src/fields.dart b/lib/src/fields.dart new file mode 100644 index 0000000..5d2ff8a --- /dev/null +++ b/lib/src/fields.dart @@ -0,0 +1,73 @@ +const _months = [ + 'jan', + 'feb', + 'mar', + 'apr', + 'may', + 'jun', + 'jul', + 'aug', + 'sep', + 'oct', + 'nov', + 'dec' +]; +const _days = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']; + +/// `normalizeFields` rewrites the five fields of an expression into numeric +/// form: `*/n` becomes `min-max/n`, and month and day names become numbers. +List normalizeFields(List fields) { + return [ + _expandStepAsterisk(fields[0], 0, 59), + _expandStepAsterisk(fields[1], 0, 23), + _expandStepAsterisk(fields[2], 1, 31), + _replaceNames(_expandStepAsterisk(fields[3], 1, 12), _months), + _replaceNames(_expandStepAsterisk(fields[4], 0, 7), _days), + ]; +} + +String _expandStepAsterisk(String field, int min, int max) { + return field.contains('/') ? field.replaceFirst('*', '$min-$max') : field; +} + +String _replaceNames(String field, List names) { + var result = field.toLowerCase(); + for (var i = 0; i < names.length; i++) { + result = result.replaceAll(names[i], '${i + 1}'); + } + return result; +} + +/// `fieldMatches` evaluates a single normalized cron field against an integer +/// [value]. +/// +/// It handles the shared grammar for every field: asterisk, skips +/// (`a-b/n`), exact values, ranges (`a-b`), and comma-separated sets +/// of any of those. When [sundayAlias] is set (day-of-week), a field value of +/// 0 also matches a [value] of 7 so that both 0 and 7 mean Sunday. +bool fieldMatches(String field, int value, {bool sundayAlias = false}) { + if (field == '*') return true; + + bool hits(int i) => i == value || (sundayAlias && i == 0 && value == 7); + + for (final part in field.split(',')) { + if (part.contains('/')) { + final s = part.split('/'); + final skips = int.parse(s[1]); + final bounds = s[0].split('-').map(int.parse).toList(); + for (var i = bounds[0]; i <= bounds[1]; i += skips) { + if (hits(i)) return true; + } + continue; + } + if (part.contains('-')) { + final bounds = part.split('-').map(int.parse).toList(); + for (var i = bounds[0]; i <= bounds[1]; i++) { + if (hits(i)) return true; + } + continue; + } + if (hits(int.parse(part))) return true; + } + return false; +} diff --git a/lib/src/validators.dart b/lib/src/validators.dart index ef0c0d5..aa5409b 100644 --- a/lib/src/validators.dart +++ b/lib/src/validators.dart @@ -1,3 +1,5 @@ +import './fields.dart'; + /// `isValid` returns true if the given string is /// a valid cron expression // TODO: support Jenkins "H"? @@ -12,11 +14,47 @@ bool isValid(String expr) { if (part.contains("/") && part.contains(",")) return false; } - return _minuteValid(parts[0]) && + if (!(_minuteValid(parts[0]) && _hourValid(parts[1]) && _dayOfMonthValid(parts[2]) && _monthValid(parts[3]) && - _dayOfWeekValid(parts[4]); + _dayOfWeekValid(parts[4]))) { + return false; + } + + final fields = normalizeFields(parts); + return fields.every(_rangesAndStepsValid) && _hasPossibleDay(fields); +} + +// A reversed range or a zero step selects no values, so the expression could +// never match and the search in [Cron.nextRelativeTo] would never end. +bool _rangesAndStepsValid(String field) { + for (final part in field.split(',')) { + final s = part.split('/'); + if (s.length == 2 && int.parse(s[1]) < 1) return false; + final bounds = s[0].split('-'); + if (bounds.length == 2 && int.parse(bounds[0]) > int.parse(bounds[1])) { + return false; + } + } + return true; +} + +// With day-of-week unrestricted, day-of-month alone selects days, and it can +// name a day that never occurs in the selected months (Feb 30). When +// day-of-week is restricted, a time matches on either field, so some day +// always matches. +bool _hasPossibleDay(List fields) { + if (fields[4] != '*') return true; + for (var month = 1; month <= 12; month++) { + if (!fieldMatches(fields[3], month)) continue; + // 2000 is a leap year, so February counts its 29th. + final days = DateTime(2000, month + 1, 0).day; + for (var day = 1; day <= days; day++) { + if (fieldMatches(fields[2], day)) return true; + } + } + return false; } bool _nicknameValid(String expr) { diff --git a/test/fixtures.dart b/test/fixtures.dart index e1d9e7c..6fe7c34 100644 --- a/test/fixtures.dart +++ b/test/fixtures.dart @@ -132,6 +132,16 @@ const List> regexTests = [ ['* * * * AAAAAA', false], ['* * * * JUS', false], ['* * * * tin', false], + // fields that select no values, or days that never occur + ['30-10 * * * *', false], + ['* * * * */0', false], + ['*/0 * * * *', false], + ['* * * 5-3 *', false], + ['* * * * sun-sat', false], + ['0 0 30 2 *', false], + ['0 0 31 apr *', false], + ['0 0 29 2 *', true], + ['0 0 30 2 mon', true], // tests for nicknames, validating `_nicknameValid` ['@reboot', false], ['@yearly', true], From 5daed5c11e8821b6668ff82042148f3413384af0 Mon Sep 17 00:00:00 2001 From: Justin Tout <3639630+justintout@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:01:36 -0400 Subject: [PATCH 5/5] Parse cron expressions with Vixie cron's rules Fields are now parsed once into sets of values, shared by isValid and Cron. The parser follows Vixie cron where the old one differed: - Fields are separated by any run of spaces or tabs. - Steps are allowed inside lists: 1-10/2,20-30/5. - * may appear in a list, and names work in month lists and ranges. - Sunday is both 0 and 7: sun-sat and fri-sun are both valid. - A day field that starts with *, such as */2, is unrestricted for the day-of-month/day-of-week rule, so the two fields combine with AND. --- README.md | 8 +- lib/src/cronparse.dart | 87 +++++---------------- lib/src/fields.dart | 154 +++++++++++++++++++++++++----------- lib/src/validators.dart | 164 +-------------------------------------- test/cronparse_test.dart | 20 +++++ test/fixtures.dart | 19 ++++- 6 files changed, 172 insertions(+), 280 deletions(-) diff --git a/README.md b/README.md index 05ec43d..c774ba5 100644 --- a/README.md +++ b/README.md @@ -35,16 +35,16 @@ void main() { ### High-level explanation on matching - if the expression is a predefined nickname, translate it to the relevant actual expression. note: this library does not parse the `@reboot` nickname. -- tokenize the expression to each part (minute, hour, day of month, month, day of week) +- tokenize the expression to each part (minute, hour, day of month, month, day of week). parts are separated by any run of spaces or tabs - test each of these tokens to match - token values can be in a few forms: - an asterisk (`*`): this always matches - an asterisk with skips (`*/15`): iterate through the valid range for the field, incrementing by the skip. if any of these match, the expression matches. - - exact value (`48`, `feb`, `WED`): match these exactly + - exact value (`48`, `feb`, `WED`): match these exactly. month and day names are case-insensitive and work anywhere a number does, including ranges (`jan-mar`, `MON-FRI`). Sunday is both `0` and `7`, so `sun` starts a range as 0 (`sun-sat`) and ends one as 7 (`fri-sun`) - range of values without skips (`5-9`): iterate through the range, starting at the lower bound and ending at the higher bound, incrementing by 1. if any of these match, the value matches - range of values with skip (`20-30/2`): iterate through the range, starting at the lower bound and ending at the higher bound, incrementing by the skip value. if any of these match, the value matches. - - set of values or ranges (`1,2,3`, `5-10,45-50`): test each value in the set using the above strategy. if any of the member values match, the value matches. -- the expression matches when all five tokens match, with one exception. when both day of month and day of week are restricted (neither is `*`), the expression matches if either of the two matches. this is the rule cron itself uses, so `0 0 13 * 5` runs on the 13th of the month and on every Friday + - set of values or ranges (`1,2,3`, `5-10,45-50`, `1-10/2,20-30/5`): test each value in the set using the above strategy. if any of the member values match, the value matches. +- the expression matches when all five tokens match, with one exception. when both day of month and day of week are restricted (neither starts with `*`), the expression matches if either of the two matches. this is the rule cron itself uses, so `0 0 13 * 5` runs on the 13th of the month and on every Friday. as in cron, a field like `*/2` starts with `*` and so does not count as restricted: `0 0 13 * */2` runs only on a 13th that falls on an even weekday ### `@reboot` diff --git a/lib/src/cronparse.dart b/lib/src/cronparse.dart index 0c8e578..54beb8b 100644 --- a/lib/src/cronparse.dart +++ b/lib/src/cronparse.dart @@ -1,5 +1,4 @@ import './fields.dart'; -import './validators.dart'; /// [Cron] is an object exposing various methods to /// calculate [DateTime]s and [Duration]s from a given cron expression. @@ -8,104 +7,54 @@ class Cron { if (expr == "@reboot") { throw ArgumentError('nickname expression "@reboot" is not supported'); } - if (!isValid(expr)) { + final fields = parseFields(expr); + if (fields == null) { throw ArgumentError('invalid cron expression: "$expr"'); } - - if (expr.startsWith("@")) { - switch (expr) { - case "@yearly": - _parsedExpr = "0 0 1 1 *"; - break; - case "@annually": - _parsedExpr = "0 0 1 1 *"; - break; - case "@monthly": - _parsedExpr = "0 0 1 * *"; - break; - case "@weekly": - _parsedExpr = "0 0 * * 0"; - break; - case "@daily": - _parsedExpr = "0 0 * * *"; - break; - case "@hourly": - _parsedExpr = "0 * * * *"; - break; - case "@midnight": - _parsedExpr = "0 0 * * *"; - break; - default: - _parsedExpr = expr; - } - } else { - _parsedExpr = expr; - } - - final field = normalizeFields(_parsedExpr.split(" ")); - _minuteField = field[0]; - _hourField = field[1]; - _dayOfMonthField = field[2]; - _monthField = field[3]; - _dayOfWeekField = field[4]; + _fields = fields; } final String expr; - late final String _parsedExpr; - late final String _minuteField; - late final String _hourField; - late final String _dayOfMonthField; - late final String _monthField; - late final String _dayOfWeekField; + late final CronFields _fields; /// `matches` returns true if the full expression matches the given time; bool matches(DateTime time) { if (!minuteMatches(time) || !hourMatches(time) || !monthMatches(time)) { return false; } - // POSIX cron day rule: when both the day-of-month and the day-of-week - // fields are restricted (neither is `*`), a time matches if EITHER field - // matches. When only one of them is restricted, only that field applies. - final domRestricted = _dayOfMonthField != '*'; - final dowRestricted = _dayOfWeekField != '*'; - if (domRestricted && dowRestricted) { - return dayOfMonthMatches(time) || dayOfWeekMatches(time); + // Vixie cron day rule: when either day field starts with `*`, a time must + // match both. Otherwise both are restricted, and a time matches if EITHER + // field matches. + if (_fields.dayOfMonthStar || _fields.dayOfWeekStar) { + return dayOfMonthMatches(time) && dayOfWeekMatches(time); } - return dayOfMonthMatches(time) && dayOfWeekMatches(time); + return dayOfMonthMatches(time) || dayOfWeekMatches(time); } /// `minuteMatches` returns true if the minute field of the expression /// matches the minute of the given time - bool minuteMatches(DateTime time) { - return fieldMatches(_minuteField, time.minute); - } + bool minuteMatches(DateTime time) => _fields.minutes.contains(time.minute); /// `hourMatches` returns true if the hour field of the expression /// matches the hour of the given time - bool hourMatches(DateTime time) { - return fieldMatches(_hourField, time.hour); - } + bool hourMatches(DateTime time) => _fields.hours.contains(time.hour); /// `dayOfMonthMatches` returns true if the day of month field of the /// expression matches the day of month of the given time - bool dayOfMonthMatches(DateTime time) { - return fieldMatches(_dayOfMonthField, time.day); - } + bool dayOfMonthMatches(DateTime time) => + _fields.daysOfMonth.contains(time.day); /// `monthMatches` returns true if the month field of the expression /// matches the month of the given time - bool monthMatches(DateTime time) { - return fieldMatches(_monthField, time.month); - } + bool monthMatches(DateTime time) => _fields.months.contains(time.month); /// `dayOfWeekMatches` returns true if the day of week field of the expression /// matches the day of week of the given time. /// /// Both 0 and 7 represent Sunday. [DateTime.weekday] uses 7 for Sunday, so - /// a cron value of 0 is treated as matching a weekday of 7. - bool dayOfWeekMatches(DateTime time) { - return fieldMatches(_dayOfWeekField, time.weekday, sundayAlias: true); - } + /// it is reduced modulo 7 to match the parsed field. + bool dayOfWeekMatches(DateTime time) => + _fields.daysOfWeek.contains(time.weekday % 7); /// `next` calculates the next [DateTime] /// the expression is scheduled for, relative to diff --git a/lib/src/fields.dart b/lib/src/fields.dart index 5d2ff8a..4a26743 100644 --- a/lib/src/fields.dart +++ b/lib/src/fields.dart @@ -1,3 +1,13 @@ +const _nicknames = { + '@yearly': '0 0 1 1 *', + '@annually': '0 0 1 1 *', + '@monthly': '0 0 1 * *', + '@weekly': '0 0 * * 0', + '@daily': '0 0 * * *', + '@midnight': '0 0 * * *', + '@hourly': '0 * * * *', +}; + const _months = [ 'jan', 'feb', @@ -12,62 +22,118 @@ const _months = [ 'nov', 'dec' ]; -const _days = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']; +const _days = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']; + +final _digits = RegExp(r'^[0-9]+$'); + +/// [CronFields] holds the values each field of a cron expression selects. +class CronFields { + CronFields._(this.minutes, this.hours, this.daysOfMonth, this.months, + this.daysOfWeek, this.dayOfMonthStar, this.dayOfWeekStar); + + final Set minutes; + final Set hours; + final Set daysOfMonth; + final Set months; -/// `normalizeFields` rewrites the five fields of an expression into numeric -/// form: `*/n` becomes `min-max/n`, and month and day names become numbers. -List normalizeFields(List fields) { - return [ - _expandStepAsterisk(fields[0], 0, 59), - _expandStepAsterisk(fields[1], 0, 23), - _expandStepAsterisk(fields[2], 1, 31), - _replaceNames(_expandStepAsterisk(fields[3], 1, 12), _months), - _replaceNames(_expandStepAsterisk(fields[4], 0, 7), _days), - ]; + /// Days of the week, where 0 is Sunday. 7 is folded into 0. + final Set daysOfWeek; + + // Vixie cron treats a day field that starts with `*` as unrestricted, even + // with a step such as `*/2`. This decides whether the day fields combine + // with AND or with OR. + final bool dayOfMonthStar; + final bool dayOfWeekStar; } -String _expandStepAsterisk(String field, int min, int max) { - return field.contains('/') ? field.replaceFirst('*', '$min-$max') : field; +/// `parseFields` parses a cron expression or nickname, following the rules of +/// Vixie cron. It returns null if the expression is malformed or can never +/// match a real date. +CronFields? parseFields(String expr) { + final expanded = expr.startsWith('@') ? _nicknames[expr] : expr.trim(); + if (expanded == null) return null; + final parts = expanded.split(RegExp(r'\s+')); + if (parts.length != 5) return null; + + final minutes = _parseField(parts[0], 0, 59); + final hours = _parseField(parts[1], 0, 23); + final daysOfMonth = _parseField(parts[2], 1, 31); + final months = _parseField(parts[3], 1, 12, names: _months, firstName: 1); + final daysOfWeek = _parseField(parts[4], 0, 7, names: _days, firstName: 0); + if (minutes == null || + hours == null || + daysOfMonth == null || + months == null || + daysOfWeek == null) { + return null; + } + if (daysOfWeek.remove(7)) daysOfWeek.add(0); + + final fields = CronFields._(minutes, hours, daysOfMonth, months, daysOfWeek, + parts[2].startsWith('*'), parts[4].startsWith('*')); + return _hasPossibleDay(fields) ? fields : null; } -String _replaceNames(String field, List names) { - var result = field.toLowerCase(); - for (var i = 0; i < names.length; i++) { - result = result.replaceAll(names[i], '${i + 1}'); +// Every field selects at least one value, so only the day fields can rule out +// every date. With both restricted, a day matches on either field, and every +// weekday occurs. Otherwise day-of-month must name a day that occurs in one of +// the selected months (Feb 30 never does). +bool _hasPossibleDay(CronFields f) { + if (!f.dayOfMonthStar && !f.dayOfWeekStar) return true; + for (final month in f.months) { + // 2000 is a leap year, so February counts its 29th. + final days = DateTime(2000, month + 1, 0).day; + if (f.daysOfMonth.any((day) => day <= days)) return true; } - return result; + return false; } -/// `fieldMatches` evaluates a single normalized cron field against an integer -/// [value]. -/// -/// It handles the shared grammar for every field: asterisk, skips -/// (`a-b/n`), exact values, ranges (`a-b`), and comma-separated sets -/// of any of those. When [sundayAlias] is set (day-of-week), a field value of -/// 0 also matches a [value] of 7 so that both 0 and 7 mean Sunday. -bool fieldMatches(String field, int value, {bool sundayAlias = false}) { - if (field == '*') return true; +/// `_parseField` returns the values a field selects, or null if the field is +/// malformed. A field is a comma-separated list of elements. Each element is +/// `*`, a value, or a range `a-b`, and `*` or a range may take a step `/n`. +Set? _parseField(String field, int min, int max, + {List names = const [], int firstName = 0}) { + int? value(String s) { + if (_digits.hasMatch(s)) return int.parse(s); + final i = names.indexOf(s.toLowerCase()); + return i < 0 ? null : i + firstName; + } - bool hits(int i) => i == value || (sundayAlias && i == 0 && value == 7); + final values = {}; + for (final element in field.split(',')) { + final stepParts = element.split('/'); + if (stepParts.length > 2) return null; + final range = stepParts[0]; + final bounds = range.split('-'); + if (bounds.length > 2) return null; - for (final part in field.split(',')) { - if (part.contains('/')) { - final s = part.split('/'); - final skips = int.parse(s[1]); - final bounds = s[0].split('-').map(int.parse).toList(); - for (var i = bounds[0]; i <= bounds[1]; i += skips) { - if (hits(i)) return true; + int? lo, hi; + if (range == '*') { + lo = min; + hi = max; + } else { + // A step needs a range to step through: `5/2` is invalid. + if (bounds.length == 1 && stepParts.length == 2) return null; + lo = value(bounds[0]); + hi = value(bounds.last); + // `sun` is 0, so a range ending on it, like `fri-sun`, uses 7 instead. + if (names == _days && bounds.length == 2 && lo != 0) { + if (bounds[1].toLowerCase() == 'sun') hi = 7; } - continue; } - if (part.contains('-')) { - final bounds = part.split('-').map(int.parse).toList(); - for (var i = bounds[0]; i <= bounds[1]; i++) { - if (hits(i)) return true; - } - continue; + + var step = 1; + if (stepParts.length == 2) { + if (!_digits.hasMatch(stepParts[1])) return null; + step = int.parse(stepParts[1]); + if (step < 1) return null; + } + if (lo == null || hi == null || lo < min || hi > max || lo > hi) { + return null; + } + for (var i = lo; i <= hi; i += step) { + values.add(i); } - if (hits(int.parse(part))) return true; } - return false; + return values; } diff --git a/lib/src/validators.dart b/lib/src/validators.dart index aa5409b..9bc8708 100644 --- a/lib/src/validators.dart +++ b/lib/src/validators.dart @@ -1,163 +1,7 @@ import './fields.dart'; -/// `isValid` returns true if the given string is -/// a valid cron expression +/// `isValid` returns true if the given string is a valid cron expression +/// that can match at least one real date. // TODO: support Jenkins "H"? -bool isValid(String expr) { - if (expr.startsWith('@')) { - return _nicknameValid(expr); - } - final parts = expr.split(' '); - if (parts.length != 5) return false; - for (final part in parts) { - if (part.contains(RegExp(r'^[0-9]+/'))) return false; - if (part.contains("/") && part.contains(",")) return false; - } - - if (!(_minuteValid(parts[0]) && - _hourValid(parts[1]) && - _dayOfMonthValid(parts[2]) && - _monthValid(parts[3]) && - _dayOfWeekValid(parts[4]))) { - return false; - } - - final fields = normalizeFields(parts); - return fields.every(_rangesAndStepsValid) && _hasPossibleDay(fields); -} - -// A reversed range or a zero step selects no values, so the expression could -// never match and the search in [Cron.nextRelativeTo] would never end. -bool _rangesAndStepsValid(String field) { - for (final part in field.split(',')) { - final s = part.split('/'); - if (s.length == 2 && int.parse(s[1]) < 1) return false; - final bounds = s[0].split('-'); - if (bounds.length == 2 && int.parse(bounds[0]) > int.parse(bounds[1])) { - return false; - } - } - return true; -} - -// With day-of-week unrestricted, day-of-month alone selects days, and it can -// name a day that never occurs in the selected months (Feb 30). When -// day-of-week is restricted, a time matches on either field, so some day -// always matches. -bool _hasPossibleDay(List fields) { - if (fields[4] != '*') return true; - for (var month = 1; month <= 12; month++) { - if (!fieldMatches(fields[3], month)) continue; - // 2000 is a leap year, so February counts its 29th. - final days = DateTime(2000, month + 1, 0).day; - for (var day = 1; day <= days; day++) { - if (fieldMatches(fields[2], day)) return true; - } - } - return false; -} - -bool _nicknameValid(String expr) { - return expr == "@yearly" || - expr == "@annually" || - expr == "@monthly" || - expr == "@weekly" || - expr == "@daily" || - expr == "@midnight" || - expr == "@hourly"; -} - -bool _minuteValid(String part) { - const int min = 0; - const int max = 59; - return _isAsterisk(part) || _hasAllPartsWithin(part, min, max); -} - -bool _hourValid(String part) { - const int min = 0; - const int max = 23; - return _isAsterisk(part) || _hasAllPartsWithin(part, min, max); -} - -// TODO: support "L", "?", "W" -bool _dayOfMonthValid(String part) { - const int min = 1; - const int max = 31; - return _isAsterisk(part) || _hasAllPartsWithin(part, min, max); -} - -bool _monthValid(String part) { - const int min = 1; - const int max = 12; - return _isAsterisk(part) || - _isMonthName(part) || - _hasAllPartsWithin(part, min, max); -} - -bool _isMonthName(String part) { - const months = [ - 'jan', - 'feb', - 'mar', - 'apr', - 'may', - 'jun', - 'jul', - 'aug', - 'sep', - 'oct', - 'nov', - 'dec' - ]; - return months.contains(part.toLowerCase()); -} - -// TODO: support "L", "?", "#" -bool _dayOfWeekValid(String part) { - const int min = 0; - const int max = 7; - return _isAsterisk(part) || - _isDayName(part) || - _hasAllPartsWithin(part, min, max); -} - -bool _isDayName(String part) { - const days = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']; - final pp = _explodeParts(part); - for (final p in pp) { - if (!days.contains(p.toLowerCase())) return false; - } - return true; -} - -bool _isWithin(int val, int min, int max) { - return val >= min && val <= max; -} - -bool _isAsterisk(String part) { - final regex = RegExp(r"^\*(/[0-9]+)?$"); - return regex.hasMatch(part); -} - -bool _isValueWithin(String part, int min, int max) { - try { - final v = int.parse(part); - return _isWithin(v, min, max); - } catch (e) { - return false; - } -} - -bool _hasAllPartsWithin(String part, int min, int max) { - final parts = _explodeParts(part); - for (final p in parts) { - if (!_isValueWithin(p, min, max)) return false; - } - return true; -} - -final _partsRegex = RegExp(r"[-\,]"); -List _explodeParts(String part) { - final p = part.split('/')[0]; - return p.split(_partsRegex); -} +// TODO: support "L", "?", "W", "#" +bool isValid(String expr) => parseFields(expr) != null; diff --git a/test/cronparse_test.dart b/test/cronparse_test.dart index b0e05e0..eb25066 100644 --- a/test/cronparse_test.dart +++ b/test/cronparse_test.dart @@ -162,6 +162,26 @@ void main() { "expression: $expression, time: $timeString, got: $result, expected: true"); }); }); + test("Sunday is 0 and 7, so `sun` can start or end a range", () { + final sunday = DateTime.parse('2019-11-24 05:00:00'); + final saturday = DateTime.parse('2019-11-23 05:00:00'); + expect(Cron('* * * * sun-sat').dayOfWeekMatches(sunday), isTrue); + expect(Cron('* * * * fri-sun').dayOfWeekMatches(sunday), isTrue); + expect(Cron('* * * * fri-sun').dayOfWeekMatches(saturday), isTrue); + expect(Cron('* * * * sun').dayOfWeekMatches(saturday), isFalse); + expect(Cron('* * * * sun-sun').dayOfWeekMatches(saturday), isFalse); + }); + test("a day field starting with `*` combines with AND", () { + // Vixie cron treats `*/2` as unrestricted for the day rule, so the + // 13th must also be an even weekday. + final cron = Cron('0 0 13 * */2'); + // 2021-11-14 is a Sunday, not the 13th. + expect(cron.matches(DateTime.parse('2021-11-14 00:00:00')), isFalse); + // 2022-04-13 is a Wednesday, an odd weekday. + expect(cron.matches(DateTime.parse('2022-04-13 00:00:00')), isFalse); + // 2021-07-13 is a Tuesday, an even weekday. + expect(cron.matches(DateTime.parse('2021-07-13 00:00:00')), isTrue); + }); }); group("DateTime calculations", () { test("nextRelativeTo returns the next matching time", () { diff --git a/test/fixtures.dart b/test/fixtures.dart index 6fe7c34..1f13e19 100644 --- a/test/fixtures.dart +++ b/test/fixtures.dart @@ -40,7 +40,7 @@ const List> regexTests = [ ['0-4,8-12,58 * * * *', true], ['4-8/2 * * * *', true], ['*/15 * * * *', true], - ['1,10-15/15 * * * *', false], + ['1,10-15/15 * * * *', true], // tests for hour, validating `_hourValid` ['* - * * *', false], ['* a * * *', false], @@ -53,7 +53,7 @@ const List> regexTests = [ ['* 12/2 * * *', false], ['* 4-8/2 * * *', true], ['* */3 * * *', true], - ['* 1,10-12/3 * * *', false], + ['* 1,10-12/3 * * *', true], // tests for day of month, validating `_dayOfMonthValid` ['* * - * *', false], ['* * a * *', false], @@ -137,11 +137,24 @@ const List> regexTests = [ ['* * * * */0', false], ['*/0 * * * *', false], ['* * * 5-3 *', false], - ['* * * * sun-sat', false], + ['* * * * sun-sat', true], ['0 0 30 2 *', false], ['0 0 31 apr *', false], ['0 0 29 2 *', true], ['0 0 30 2 mon', true], + ['* * * * fri-sun', true], + ['* * * * 5-0', false], + // Vixie cron grammar + ['0 0 * * *', true], + ['0\t0 * * *', true], + [' 0 0 * * * ', true], + ['1-10/2,20-30/5 * * * *', true], + ['*,5 * * * *', true], + ['* * * jan-mar *', true], + ['* * * jan,FEB *', true], + ['* * * jan/2 *', false], + ['* * * * */mon', false], + ['1,,2 * * * *', false], // tests for nicknames, validating `_nicknameValid` ['@reboot', false], ['@yearly', true],