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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
5 changes: 4 additions & 1 deletion lib/cronparse.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
112 changes: 60 additions & 52 deletions lib/src/cronparse.dart
Original file line number Diff line number Diff line change
@@ -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');
Expand All @@ -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;
Expand All @@ -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) -
Expand All @@ -82,58 +99,49 @@ 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) +
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));
}
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);
}
Expand Down
8 changes: 6 additions & 2 deletions lib/src/validators.dart
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
19 changes: 7 additions & 12 deletions test/cronparse_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down Expand Up @@ -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",
Expand All @@ -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);
Expand Down
Loading