From 6e959227f00e2b5c10f26a090a99c035c068021c Mon Sep 17 00:00:00 2001 From: Justin Tout <3639630+justintout@users.noreply.github.com> Date: Mon, 5 Oct 2026 02:43:29 -0400 Subject: [PATCH 1/3] Document the whole public API and add pub.dev badges Every public member now has a dartdoc comment that states what it returns, not just its name. The Cron constructor documents what it accepts and when it throws, and the class has a usage example. The README gets version and docs badges. --- README.md | 4 ++ lib/cronparse.dart | 5 ++- lib/src/cronparse.dart | 99 ++++++++++++++++++++++------------------- lib/src/validators.dart | 8 +++- 4 files changed, 67 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index c774ba5..ba5b935 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,8 @@ # cronparse + +[![pub package](https://img.shields.io/pub/v/cronparse.svg)](https://pub.dev/packages/cronparse) +[![docs](https://img.shields.io/badge/docs-pub.dev-blue)](https://pub.dev/documentation/cronparse/latest/) + > Parse and calculate different times related to Unix cron expressions Parse Unix cron expressions and calculate things like: diff --git a/lib/cronparse.dart b/lib/cronparse.dart index 423ed97..9cd2d1e 100644 --- a/lib/cronparse.dart +++ b/lib/cronparse.dart @@ -1,4 +1,7 @@ -/// Parse and calculate different times related to Unix cron expressions +/// Parse Unix cron expressions and find the times they are scheduled for. +/// +/// Use [isValid] to check an expression, and [Cron] to match it against a +/// time or find its next and previous scheduled times. library cronparse; export './src/cronparse.dart'; diff --git a/lib/src/cronparse.dart b/lib/src/cronparse.dart index 54beb8b..cd7573f 100644 --- a/lib/src/cronparse.dart +++ b/lib/src/cronparse.dart @@ -1,8 +1,27 @@ import './fields.dart'; - -/// [Cron] is an object exposing various methods to -/// calculate [DateTime]s and [Duration]s from a given cron expression. +import './validators.dart'; + +/// A parsed cron expression that finds the times it is scheduled for. +/// +/// ```dart +/// final cron = Cron('0 22 * * MON-FRI'); +/// final time = DateTime.parse('2019-11-22 16:00:00'); // a Friday +/// cron.nextRelativeTo(time); // 2019-11-22 22:00:00.000 +/// cron.untilNextRelativeTo(time); // 6:00:00.000000 +/// ``` +/// +/// Expressions follow Vixie cron, the `cron` in most Linux distributions. +/// See `crontab(5)` for the grammar. Times are compared using the fields of +/// the [DateTime] given, so a local time is matched in local time and a UTC +/// time in UTC. class Cron { + /// Parses [expr], a five-field cron expression or a nickname such as + /// `@daily`. + /// + /// Throws an [ArgumentError] if [expr] is not a valid expression, if it can + /// never match a real date (such as `0 0 30 2 *`), or if it is `@reboot`, + /// which names an event rather than a time. [isValid] reports the same + /// errors without throwing. Cron(this.expr) { if (expr == "@reboot") { throw ArgumentError('nickname expression "@reboot" is not supported'); @@ -14,10 +33,15 @@ class Cron { _fields = fields; } + /// The expression as passed to the constructor. final String expr; late final CronFields _fields; - /// `matches` returns true if the full expression matches the given time; + /// Whether [time] falls on a minute the expression is scheduled for. + /// + /// Seconds and smaller units of [time] are ignored. As in cron, when both + /// day fields are restricted (neither starts with `*`), [time] matches if + /// either day field matches. Otherwise both must match. bool matches(DateTime time) { if (!minuteMatches(time) || !hourMatches(time) || !monthMatches(time)) { return false; @@ -31,44 +55,37 @@ class Cron { return dayOfMonthMatches(time) || dayOfWeekMatches(time); } - /// `minuteMatches` returns true if the minute field of the expression - /// matches the minute of the given time + /// Whether the minute field selects the minute of [time]. 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 + /// Whether the hour field selects the hour of [time]. 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 + /// Whether the day-of-month field selects the day of [time]. 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 + /// Whether the month field selects the month of [time]. 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. + /// Whether the day-of-week field selects the weekday of [time]. /// - /// Both 0 and 7 represent Sunday. [DateTime.weekday] uses 7 for Sunday, so - /// it is reduced modulo 7 to match the parsed field. + /// In the expression, both 0 and 7 mean Sunday. bool dayOfWeekMatches(DateTime time) => _fields.daysOfWeek.contains(time.weekday % 7); - /// `next` calculates the next [DateTime] - /// the expression is scheduled for, relative to - /// the current time + /// The next time the expression is scheduled for after now. + /// + /// See [nextRelativeTo]. DateTime next() { return nextRelativeTo(DateTime.now()); } - /// `nextRelativeTo` calculates the next [DateTime] - /// the expression is scheduled for, relative to - /// the given time. + /// The first time the expression is scheduled for after [time]. /// - /// `nextRelativeTo` uses a naive strategy to find the next time by - /// searching forward each minute until a match is found. + /// The result is always a whole minute and is never [time] itself. The + /// search steps forward one minute at a time, so a rare schedule such as + /// `0 0 29 2 *` (February 29th) can take up to about a second. DateTime nextRelativeTo(DateTime time) { // round the given time to the next exact minute to start the search time = time.add(Duration(minutes: 1) - @@ -82,19 +99,18 @@ class Cron { return time; } - /// `previous` calculates the last [DateTime] - /// the expression would have been scheduled for, - /// relative to the current time + /// The last time the expression was scheduled for before now. + /// + /// See [previousRelativeTo]. DateTime previous() { return previousRelativeTo(DateTime.now()); } - /// `previousRelativeTo` calculates the last [DateTime] - /// the expression would have been scheduled for, - /// relative to the given time + /// The last time the expression was scheduled for before [time]. /// - /// `previousRelativeTo` uses a naive strategy to find the previous time by - /// searching backward each minute until a match is found. + /// The result is always a whole minute and is never [time] itself. The + /// search steps backward one minute at a time, so a rare schedule can take + /// up to about a second. DateTime previousRelativeTo(DateTime time) { // round the given time to the previous exact minute to start the search time = time.subtract(Duration(minutes: 1) + @@ -108,32 +124,23 @@ class Cron { return time; } - /// `untilNext` returns the [Duration] until - /// the expression's next scheduled time, relative - /// to the current time + /// The time from now until [next]. Always positive. Duration untilNext() { return untilNextRelativeTo(DateTime.now()); } - /// `untilNextRelativeTo` returns the [Duration] until - /// the expression's next scheduled time, relative - /// to the given time + /// The time from [time] until [nextRelativeTo] of [time]. Always positive. Duration untilNextRelativeTo(DateTime time) { return nextRelativeTo(time).difference(time); } - /// `sincePrevious` calculates the [Duration] - /// since the previous time the expression - /// would have been scheduled for, relative to - /// the current time + /// The time from now back to [previous]. Always negative. Duration sincePrevious() { return sincePreviousRelativeTo(DateTime.now()); } - /// `sincePreviousRelativeTo` calculates the [Duration] - /// since the previous time the expression - /// would have been scheduled for, relative to - /// the given time + /// The time from [time] back to [previousRelativeTo] of [time]. Always + /// negative, matching [DateTime.difference]. Duration sincePreviousRelativeTo(DateTime time) { return previousRelativeTo(time).difference(time); } diff --git a/lib/src/validators.dart b/lib/src/validators.dart index 9bc8708..a47af4d 100644 --- a/lib/src/validators.dart +++ b/lib/src/validators.dart @@ -1,7 +1,11 @@ +import './cronparse.dart'; import './fields.dart'; -/// `isValid` returns true if the given string is a valid cron expression -/// that can match at least one real date. +/// Whether [expr] is a cron expression that [Cron] accepts. +/// +/// [expr] is valid if it follows the Vixie cron grammar and matches at least +/// one real date. `0 0 30 2 *` is well formed, but February never has a 30th, +/// so it is not valid. The nickname `@reboot` is not valid either. // TODO: support Jenkins "H"? // TODO: support "L", "?", "W", "#" bool isValid(String expr) => parseFields(expr) != null; From f51db3775b6bc1d7e6cc39693eb74b292b884b98 Mon Sep 17 00:00:00 2001 From: Justin Tout <3639630+justintout@users.noreply.github.com> Date: Mon, 5 Oct 2026 02:47:27 -0400 Subject: [PATCH 2/3] Return a match earlier in the same minute from previousRelativeTo previousRelativeTo started its search a full minute plus the leftover seconds before the given time, so a match earlier in the same minute was skipped: */15 * * * * at 16:00:30 returned 15:45 instead of 16:00. The search now starts at the whole minute at or before the given time, excluding the given time itself. Four test rows asserted the old result and are corrected. --- lib/src/cronparse.dart | 13 +++++++------ test/cronparse_test.dart | 19 +++++++------------ 2 files changed, 14 insertions(+), 18 deletions(-) diff --git a/lib/src/cronparse.dart b/lib/src/cronparse.dart index cd7573f..3f0b5cb 100644 --- a/lib/src/cronparse.dart +++ b/lib/src/cronparse.dart @@ -112,12 +112,13 @@ class Cron { /// search steps backward one minute at a time, so a rare schedule can take /// up to about a second. DateTime previousRelativeTo(DateTime time) { - // round the given time to the previous exact minute to start the search - time = time.subtract(Duration(minutes: 1) + - Duration( - seconds: time.second, - milliseconds: time.millisecond, - microseconds: time.microsecond)); + // Start from the whole minute at or before time. A time already on a whole + // minute is excluded, so start one minute earlier. + final minute = time.subtract(Duration( + seconds: time.second, + milliseconds: time.millisecond, + microseconds: time.microsecond)); + time = minute == time ? minute.subtract(Duration(minutes: 1)) : minute; while (!matches(time)) { time = time.subtract(Duration(minutes: 1)); } diff --git a/test/cronparse_test.dart b/test/cronparse_test.dart index eb25066..54cadda 100644 --- a/test/cronparse_test.dart +++ b/test/cronparse_test.dart @@ -209,13 +209,16 @@ void main() { final tests = [ // simple tests ["* * * * *", "2019-11-23 12:00:00", "2019-11-23 11:59:00"], - ["* * * * *", "2019-11-23 12:00:01", "2019-11-23 11:59:00"], + ["* * * * *", "2019-11-23 12:00:01", "2019-11-23 12:00:00"], ["5 * * * *", "2019-11-23 12:00:00", "2019-11-23 11:05:00"], ["0 13 * * *", "2019-11-23 12:00:00", "2019-11-22 13:00:00"], // TODO: range tests // TODO: set tests // TODO: skip tests - ["*/5 * * * *", "2019-11-23 12:00:01", "2019-11-23 11:55:00"], + ["*/5 * * * *", "2019-11-23 12:00:01", "2019-11-23 12:00:00"], + // a match earlier in the same minute is still before the given time + ["*/15 * * * *", "2019-11-23 16:00:30", "2019-11-23 16:00:00"], + ["*/15 * * * *", "2019-11-23 16:00:00", "2019-11-23 15:45:00"], ]; for (final t in tests) { final cron = Cron(t[0]); @@ -259,11 +262,7 @@ void main() { final tests = [ // simple tests ["* * * * *", "2019-11-23 12:00:00", -Duration(minutes: 1)], - [ - "* * * * *", - "2019-11-23 12:00:01", - -Duration(minutes: 1, seconds: 1) - ], + ["* * * * *", "2019-11-23 12:00:01", -Duration(seconds: 1)], [ "5 * * * *", "2019-11-23 12:00:01", @@ -277,11 +276,7 @@ void main() { // TODO: range tests // TODO: set tests // TODO: skip tests - [ - "*/5 * * * *", - "2019-11-23 12:00:01", - -Duration(minutes: 5, seconds: 1) - ], + ["*/5 * * * *", "2019-11-23 12:00:01", -Duration(seconds: 1)], ]; for (final t in tests) { final cron = Cron(t[0] as String); From 3f14bf55c58cf957ebe61afde8aa806b43c02c66 Mon Sep 17 00:00:00 2001 From: Justin Tout <3639630+justintout@users.noreply.github.com> Date: Mon, 5 Oct 2026 02:47:27 -0400 Subject: [PATCH 3/3] Release 0.3.1 --- CHANGELOG.md | 7 +++++++ pubspec.yaml | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fcba64a..be21df7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [0.3.1] - 2026-10-05 +* Fix `previousRelativeTo`, `previous`, `sincePreviousRelativeTo` and + `sincePrevious`, which skipped a match earlier in the same minute. + `*/15 * * * *` at 16:00:30 now returns 16:00 instead of 15:45. +* Document the whole public API, including what the `Cron` constructor + accepts and when it throws. + ## [0.3.0] - 2026-10-05 * **Breaking:** `isValid` rejects expressions that can never match, and `Cron` throws an `ArgumentError` for them. These are reversed ranges (`30-10`), zero diff --git a/pubspec.yaml b/pubspec.yaml index e89da4e..ae33471 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: cronparse description: Parse and calculate different times related to Unix cron expressions -version: 0.3.0 +version: 0.3.1 homepage: https://github.com/justintout/cronparse dev_dependencies: