cronp 0.3.0

A blazing fast no-std, no-alloc cron expression parser with multiple dialects support
Documentation

A blazing fast no-std, no-alloc cron expression parser, with multiple dialects support.

Installation

[dependencies]
cronp = "0.3"

What it does

Parses a cron expression into a fixed-size value that never allocates, and answers whether a caller-supplied instant matches it. No clock, no calendar arithmetic, and nothing behind a feature on the default path.

The dialect is a type parameter, not a runtime tag, because the dialects disagree about what a stored schedule means and not merely about what text they accept:

use cronp::{CivilDateTime, DateError, ParseError, Quartz, Schedule, Vixie};

fn example() -> Result<(), ParseError> {
    // Five fields, the shape crontab(5) takes.
    let nightly: Schedule<Vixie> = Schedule::parse("30 2 * * 1-5")?;

    // Six or seven, a leading seconds field, and `?` in one of the two day fields.
    let quartz = Schedule::<Quartz>::parse("0 15 10 ? * MON-FRI")?;

    // Five fields is not Quartz, and the error says so.
    assert!(Schedule::<Quartz>::parse("30 2 * * 1-5").is_err());

    // And the question a schedule exists to answer. 2026-08-12 is a Wednesday.
    let when = CivilDateTime::new(2026, 8, 12, 2, 30, 0).map_err(as_parse_error)?;
    assert!(nightly.matches(when));

    let _ = quartz;
    Ok(())
}

// The example returns one error type; a date that does not exist is a different failure
// from an expression that does not parse.
fn as_parse_error(_: DateError) -> ParseError {
    unreachable!("2026-08-12T02:30:00 exists")
}

Dialects

fields Sunday is both day fields restricted extras
Vixie 5 0, and 7 too union nicknames, @reboot
Quartz 6 or 7 1 rejected; one must be ? L, LW, L-n, nW, n#m
Robfig 6 0 union nicknames, @every <duration>
Cronexpr 5 0, and 7 too union trailing IANA timezone, H, Quartz's predicates

The same digit means different days in the first two, so a crontab written with digits is not portable between them and one written with names is.

The union rule reads the items, not the set

Where both day fields are restricted, Vixie fires on either — unless a day field carries the wildcard, in which case the two are combined with "and" instead. That is not a question about the set of days a field denotes, so no accessor over the stored days could answer it: *,10 and 10,* are one set written two ways and behave differently. It is the documented cron bug.

Schedule::matches applies the rule; there is no accessor to apply it with, because the witness is not a property of the days a field denotes and no question about them could carry it. Which items count is WildcardWitness, and it sits inside DomDowRule::Union because a dialect that refuses two restricted day fields never asks. The dialects disagree in both directions, so it is a dialect's declaration rather than a syntactic constant:

*,10 10,* */2 ?
Vixie, CronexprLeadingStar, the field's first item wildcard wildcard n/a
RobfigAnyUnconstrained, any item that narrows nothing wildcard wildcard wildcard

H, and where the seed comes from

Cronexpr reads H as a value chosen by hashing a caller-supplied seed into the field's own values, so H 0 * * * fires at the same caller-specific minute every hour and different callers spread across the range. The seed arrives at runtime and so cannot be a dialect constant: Schedule::parse_with(input, seed) is the entry point that carries one, and plain parse reports that it is missing.

The fold is over the values a field has, not over the ways they can be written, and day-of-week is where those two counts differ: 0 and 7 are both Sunday, so eight digits name seven days. cronexpr itself folds over the eight, which gives Sunday two of its buckets and twice its share of the work; this crate folds over the seven, so every day gets one. It is the one place a seed picks a different value here than there.

Timezones

An expression in a dialect that declares Dialect::TIMEZONE may end with an IANA name. ZonedSchedule is the type that parses one — a sibling of Schedule rather than a lifetime bolted onto it, because a schedule with a timezone denotes different instants from one without. What the crate can do with the name afterwards is a question about features, not about dialects; see the table below.

The year range is on the type

Schedule<D> is Schedule<D, 1>, whose years are one u128 over a 1970 epoch: 1970..=2097. That is short of both reference implementations on purpose — the range is something the type states rather than something this crate quietly decided — and a year beyond it is rejected by name:

year field at 12..16: 2098 is legal cron but this schedule represents only up to 2097;
instantiate it with N = 2

Benchmarks

Parse only — text in, a schedule out, dropped inside the timed region. No matching, no next-occurrence computation. Criterion, aarch64-apple-darwin (M4 Pro), stable 1.97.1, against saffron 0.1, cron 0.17, croner 3 and cronexpr 1.6.

A library appears in a row only where it accepts that shape. saffron is five-field only, and cron does not take a bare five-field expression, so neither is in every row — timing a parse against a rejection would compare a built schedule with an error return. cronexpr wants five fields plus a timezone and has no seconds field, no year field and no nickname macros, so it is absent from the 6-field, 7-field and nickname rows.

cronexpr requires the timezone by default; its cells here use parse_crontab_with in FallbackTimezoneOption::UTC mode, called with no timezone field in the input — the same five fields every other column parses. With nothing after the day-of-week field, cronexpr never scans or resolves a timezone at all; it falls straight through to the constant jiff::tz::TimeZone::UTC. That keeps its cells comparable to three parsers that do no timezone work of any kind, but it is not how cronexpr is meant to be called — its whole reason for existing is the timezone written into the expression, and this table does not charge it for that. cargo bench also reports cronexpr/timezone cost (informational), outside this table: the same expression once with no timezone and once with an explicit Asia/Shanghai through cronexpr's default, required-timezone mode. Resolving the name costs about 38 ns more on this machine, a further ~5% on top of the 732 ns already below.

Bold is the fastest cell in the row, not cronp's column.

cronp saffron cron croner cronexpr†
5-field 30 2 * * 1-5 37.6 ns 38.4 ns 8.61 µs 719 ns
5-field lists, steps, names 100.8 ns 146.5 ns 9.27 µs 754 ns
6-field 0 30 2 * * 1-5 44.9 ns 569 ns 8.77 µs
7-field Quartz with year 96.0 ns 802 ns 1.48 µs
nickname @daily 9.7 ns 99.4 ns 8.58 µs
rejected 0 0 * * 99 36.2 ns 37.8 ns 523 ns 1.01 µs 757 ns

† No timezone resolved; see above.

Features

Two groups, and they are kept apart on purpose. Propagation says what tier the build is, and every optional dependency learns it: alloc and std reach jiff through weak dependency features, so --features std,jiff is jiff in its std mode rather than jiff quietly staying no_std inside a std build. Neither pulls the dependency in on its own. Selection says which capability is compiled — with jiff the odd one out, because it names the dependency the other two are built on and delivers no capability by itself.

feature effect
(default) no_std, no alloc: parse, represent, match. A timezone in the expression is retained as a borrowed &str and resolved by nobody.
alloc, std the tier of the build, propagated into every optional dependency. No owned diagnostics reach them yet.
jiff pulls the jiff dependency and nothing else: no API of this crate appears or changes, and no civil::DateTime conversion exists in any build. It is the base the two rows below add a capability to, and on its own it selects none. Still no_std.
tz-static resolve a timezone against a table the application names at compile time, through ZonedSchedule::resolve_in. Still no_std, still no alloc: jiff's static feature requires neither.
tz resolve any IANA name at runtime, through ZonedSchedule::resolve. Needs std and an allocator, and pulls jiff's bundled/system tzdb.

Every row above that says no_std is built for a bare-metal target in CI, in a cell of the no-std job named for it, and tests/no_std.rs fails if a new feature arrives without either a cell or a statement that its tier needs a host. Being exercised on a host says nothing about a row: the tier tests in tests/public_api.rs run in a graph where the cronexpr dev-dependency enables jiff/default, which is the std and alloc these rows disclaim.

tz-static and tz are different capabilities rather than two sizes of one. The static tier resolves exactly what was compiled in and refuses everything else; the runtime tier needs no registration at all. An application that knows its timezones can have the first on bare metal.

One boundary on that last sentence, because it is a build-level requirement rather than something this crate can satisfy for you. Every tier that pulls the jiff dependency reaches portable-atomic through it, and portable-atomic needs atomic compare-and-swap: that is jiff, tz-static and tz alike, not tz-static alone. Targets that have it — thumbv7em-none-eabi and the rest of Cortex-M3 and up — build as they stand. On a target without it, such as thumbv6m-none-eabi (Cortex-M0), portable-atomic requires the binary to choose either its critical-section feature or unsafe-assume-single-core; that is a leaf-crate decision by design, and a library must not make it on your behalf. With the choice made, tz-static builds there too — the no-std job's Cortex-M0 cell passes --cfg portable_atomic_unsafe_assume_single_core, the second of those two choices, and goes red on portable-atomic's own compile_error! without it. The default tier reaches none of this and builds on both targets untouched.

What is not here

Next-occurrence, iteration, and any timezone-aware matching. This crate parses, represents, and answers whether a schedule fires at a civil instant; ZonedSchedule::matches and "when does it fire next" are separate features and are not in it.

Schedule::matches is deliberately the only way to ask. The per-field admits_* predicates that remain — seconds, minutes, hours, months, years — are the ones that combine with nothing but "and", so a caller can read them without a rule. The two day fields have no such accessor: combining them takes the dialect's rule, that rule keys on how each field was written, and a field carrying L or 15W has an empty bitset anyway. Exporting the terms of that decision and leaving the caller to assemble them is how four wrong answers shipped, and it is what tests/matcher_differential.rs — the matcher against cronexpr, cron, croner and saffron over a corpus of expressions and a year of instants — now stands in the way of.

License

cronp is under the terms of both the MIT license and the Apache License (Version 2.0).

See LICENSE-APACHE, LICENSE-MIT for details.

Copyright (c) 2026 Al Liu.