Cron::Toolkit - Cron parser, describer, and scheduler with full Quartz support
use Cron::Toolkit;
use Time::Moment; # For epoch examples
# Standard constructor (auto-detects Unix/Quartz)
my $cron = Cron::Toolkit->new(
expression => "0 30 14 * * 1#3 ?",
time_zone => "America/New_York" # Or utc_offset => -300
);
# Unix-specific constructor
my $unix_cron = Cron::Toolkit->new_from_unix(
expression => "30 14 * * MON" # Unix 5-field
);
# Quartz-specific constructor
my $quartz_cron = Cron::Toolkit->new_from_quartz(
expression => "0 30 14 * * MON 2025" # Quartz 6/7-field
);
# Crontab file or string (supports @aliases)
my @crons = Cron::Toolkit->new_from_crontab('/etc/crontab');
$cron->begin_epoch(Time::Moment->new(year => 2025, month => 1, day => 1)->epoch); # Bound to 2025-01-01
$cron->end_epoch(Time::Moment->new(year => 2025, month => 12, day => 31)->epoch); # Bound to 2025-12-31
say $cron->describe; # "at 2:30 PM on the third Sunday of every month"
say $cron->describe(locale => 'en'); # English (stub for locales like 'fr')
say $cron->is_match(time) ? "RUN NOW!" : "WAIT";
say $cron->next; # Next matching epoch after begin_epoch or now (within end)
say $cron->previous; # Previous matching epoch before now
my $nexts = $cron->next_n(3); # Or $cron->next_occurrences(3)
say join ", ", map { Time::Moment->from_epoch($_)->strftime('%Y-%m-%d %H:%M:%S') } @$nexts;
# Utils
say $cron->as_string; # "0 30 14 * * ? *"
use JSON::MaybeXS; say decode_json($cron->to_json); # Hash of attrs
$cron->dump_tree; # Pretty-print AST
Cron::Toolkit is a comprehensive Perl module for parsing, describing, and scheduling cron expressions. It evolved from a descriptive focus into a versatile toolkit for cron manipulation, featuring timezone-aware matching, bounded searches, and complete Quartz enterprise syntax support (seconds field, L/W/#, steps, ranges, lists).
Key features:
- Natural Language Descriptions: Generates readable English like "at 2:30 PM on the third Monday of every month in 2025". Extensible for locales via Composer/Visitor.
- Timezone Awareness: Supports time_zone (e.g., "America/New_York") or utc_offset (-300 minutes) for local-time matching and next/previous calculations. Uses DateTime::TimeZone for DST handling.
- Bounded Searches: Optional begin_epoch/end_epoch limits next/previous to a time window, preventing infinite loops or off-by-one errors.
- AST Architecture: Tree-based parsing with Pattern nodes (Single, Range, Step, List, Last, Nth, NearestWeekday). Dual visitors for description (Composer + EnglishVisitor) and matching (Matcher + MatchVisitor)—easy to extend for custom patterns or locales.
- Quartz Compatibility: Full support for seconds field, L (last day), W (nearest weekday), # (nth DOW), steps/ranges/lists. Unix 5-field auto-converts to Quartz (adds seconds=0, year=*).
- Production-Ready: 50+ tests covering edges like leap years, month lengths, DOW normalization, DST flips, and bounded clamps. Handles @aliases (@hourly, etc.) in expressions and crontabs.
Cron::Toolkit employs an Abstract Syntax Tree (AST) for robust expression handling:
- Parse: TreeParser constructs Pattern nodes from fields (Single for 15, Range for 1-5, Step for */15, List for 1,15, Last for L, Nth for 1#3, NearestWeekday for 15W).
- Describe: Composer fuses node outputs via templates, using EnglishVisitor (or locale subclass) for human-readable text.
- Match: Matcher evaluates recursively against timestamps, using MatchVisitor for field-by-field checks (context-aware for L/nth/W).
This separation enables extensibility: Subclass Visitor for new locales (e.g., FrenchVisitor) or patterns (add parse clause + visit method).
my $cron = Cron::Toolkit->new(
expression => "0 30 14 * * ?",
time_zone => "America/New_York", # Auto-calculates offset (DST-aware)
utc_offset => -300, # Minutes from UTC (overrides time_zone if both set)
begin_epoch => 1640995200, # Optional: Start bound (default: time)
end_epoch => 1672531200, # Optional: End bound (default: undef/unbounded)
);
Primary constructor. Auto-detects Unix (5 fields) or Quartz (6/7 fields). Supports @aliases (@hourly → "0 0 * * * ? *"). Normalizes to 7-field Quartz internally.
Parameters:
- expression: Required cron string or @alias.
- time_zone: Optional TZ string (e.g., "America/New_York"); auto-calculates utc_offset if not set.
- utc_offset: Optional minutes from UTC (-1080 to +1080); overrides time_zone calc.
- begin_epoch: Optional non-negative epoch; floors searches (default: time).
- end_epoch: Optional non-negative epoch; caps searches (default: unbounded).
Returns: Blessed Cron::Toolkit object.
my $unix_cron = Cron::Toolkit->new_from_unix(
expression => "30 14 * * MON"
);
Unix-specific constructor for 5-field expressions. Auto-converts to Quartz (adds seconds=0, year=*, normalizes DOW: MON=1→2, SUN=0→1).
Parameters: Same as "new", but expression must be 5 fields.
my $quartz_cron = Cron::Toolkit->new_from_quartz(
expression => "0 30 14 * * MON 2025"
);
Quartz-specific constructor for 6/7-field expressions. Validates and normalizes (adds year=* if 6 fields, DOW names to numbers).
Parameters: Same as "new", but expression must be 6/7 fields.
my @crons = Cron::Toolkit->new_from_crontab('/etc/crontab'); # Or string
Parses a crontab file or string into array of Cron::Toolkit objects. Skips comments (#), empty lines, invalid exprs (warns). Supports @aliases (@hourly → "0 0 * * * ? *").
Parameters:
- input: File path or multi-line string.
Returns: Array of valid objects (empty if none).
my $english = $cron->describe;
my $french = $cron->describe(locale => 'fr'); # Stub; falls back to English
Returns human-readable description with fused combos (e.g., "at 2:30 PM on the third Monday of every month"). Defaults to English; locale param for extensibility (warns on unsupported, e.g., 'fr'—extend via Visitor subclass).
my $match = $cron->is_match($epoch_seconds); # True/false
Returns true if the timestamp matches the cron in the object's timezone (local time, DST-aware).
Parameters:
- epoch_seconds: Non-negative Unix timestamp (UTC).
my $next_epoch = $cron->next($epoch_seconds);
my $next_epoch = $cron->next; # Defaults to begin_epoch or time
Returns the next matching epoch after the given/current time, clamped >= begin_epoch and <= end_epoch (undef if none).
Parameters:
- epoch_seconds: Optional non-negative timestamp (defaults: begin_epoch // time, clamped to bounds).
my $prev_epoch = $cron->previous($epoch_seconds);
my $prev_epoch = $cron->previous; # Defaults to time
Returns the previous matching epoch before the given/current time, clamped <= end_epoch and >= begin_epoch (undef if none).
Parameters:
- epoch_seconds: Optional non-negative timestamp (defaults: time, clamped to bounds).
my $next_epochs = $cron->next_n($epoch_seconds, $n, $max_iter);
my $next_epochs = $cron->next_n(undef, $n); # Defaults: time, n=1, max_iter=10000
Returns arrayref of the next $n matching epochs after the given/current time, clamped to bounds. Guards against loops with max_iter (dies on exceed).
Parameters:
- epoch_seconds: Optional start timestamp (defaults: time).
- n: Number of occurrences (defaults: 1).
- max_iter: Max iterations (defaults: 10000; dies if exceeded).
Returns: Arrayref of epochs (empty if none).
Alias for "next_n". Same parameters and return.
say $cron->begin_epoch; # Current value
$cron->begin_epoch(1640995200); # Set to 2022-01-01 UTC
Gets/sets the start epoch for bounded searches (non-negative integer or undef). Clamps next/previous from this time onward (defaults: time if undef).
say $cron->end_epoch; # undef or current value
$cron->end_epoch(1672531200); # Set to 2023-01-01 UTC
$cron->end_epoch(undef); # Unbounded
Gets/sets the end epoch for bounded searches (non-negative integer or undef). Caps next/previous at this time (defaults: unbounded if undef).
say $cron->utc_offset; # -300
$cron->utc_offset(-480); # Switch to PST
Gets/sets UTC offset in minutes (-1080 to +1080). Validates input; overrides time_zone calc.
say $cron->time_zone; # "America/New_York"
$cron->time_zone("Europe/London"); # Recalcs utc_offset (DST-aware)
Gets/sets time zone string (e.g., "America/New_York"). Validates via DateTime::TimeZone; recalculates utc_offset on set (current DST).
say $cron->as_string; # "0 30 14 * * ? *"
Returns the normalized Quartz expression as a string.
say $cron->to_json; # '{"expression":"0 30 14 * * ? *", ...}'
Returns JSON-encoded hash of core attributes (expression, description, utc_offset, time_zone, begin_epoch, end_epoch). Requires JSON::MaybeXS.
$cron->dump_tree; # Prints indented AST to STDOUT
Pretty-prints the AST root (or pass a node). Recursive indent for types/values/children.
- Basic: "0 30 14 * * ?"
- Steps: "*/15", "5/3", "10-20/5"
- Ranges: "1-5", "10-14"
- Lists: "1,15", "MON,WED,FRI"
- Last Day: "L", "L-2", "LW"
- Nth DOW: "1#3" = "3rd Sunday"
- Weekday: "15W" = "nearest weekday to 15th"
- Seconds Field: "0 0 30 14 * * ? *" (7-field)
- Names: JAN-MAR, MON-FRI (normalized; mixed-case OK)
- Aliases: @hourly, @daily, @monthly, etc. (Vixie-style, mapped to Quartz)
Unix 5-field auto-converted to Quartz (adds seconds=0, year=*, DOW normalize: MON=1→2, SUN=0→1).
my $ny_open = Cron::Toolkit->new(
expression => "0 30 9.5 * * 2-6 ?",
time_zone => "America/New_York"
);
say $ny_open->describe; # "at 9:30 AM every Monday through Friday"
my $backup = Cron::Toolkit->new(
expression => "0 0 2 LW * ? *",
time_zone => "Europe/London"
);
$backup->begin_epoch(Time::Moment->new(year => 2025, month => 1, day => 1)->epoch);
$backup->end_epoch(Time::Moment->new(year => 2025, month => 4, day => 1)->epoch);
if ($backup->is_match(time)) {
system("backup.sh");
}
my $third_mon = Cron::Toolkit->new(expression => "0 0 0 * * 2#3 ? 2025");
say $third_mon->describe; # "at midnight on the third Monday in 2025"
my $sec_cron = Cron::Toolkit->new_from_quartz(
expression => "0 0 30 14 * * ? *"
);
say $sec_cron->describe; # "at 2:30:00 PM every month"
my @crons = Cron::Toolkit->new_from_crontab('my_tab');
my $cron = $crons[0];
say $cron->next_occurrences(3); # Next 3 epochs
say decode_json($cron->to_json)->{description}; # JSON attrs
$ENV{Cron_DEBUG} = 1;
$cron->utc_offset(-300); # "DEBUG: utc_offset: set to -300"
$cron->dump_tree; # AST structure
Nathaniel J Graham ngraham@cpan.org
Copyright 2025 Nathaniel J Graham.
This program is free software; you can redistribute it and/or modify it under the terms of the Artistic License (2.0).