v2.0.0
hron 2.0.0
hron 2.0 makes every implementation keep one exact contract. The same expression gives the same occurrences, the same cron conversion, the same error message and span, and the same public API in Rust, TypeScript, WebAssembly, Python, Go, Java, C#, Ruby and Dart. Shared spec files define that contract, and every package's tests run against them (#84, #95, #96, #98, #99, #101). This release also adds a ninth implementation, a native Swift package (#102).
It is a major version because getting there changes public APIs in every package. Some names are renamed, internals are no longer exported, and schedules can no longer be changed after they are built. Wrong-type and null arguments now raise each platform's own usage error. Some inputs that used to parse are now rejected, and every error message has new text. Each break is listed below by package.
Highlights
- One contract, checked everywhere.
spec/tests.jsongained hundreds of fixed-answer cases and a set of invariants (#84).spec/build.jsoncovers schedules built in code (#99), andspec/api.jsoncovers the public API of every language (#101). Each runner fails on any case it cannot check (#84). - Correct evaluation in every language. The implementations used to disagree on more than a thousand generated cases, and in 735 more they agreed on a wrong answer. These are fixed, and DST handling is now the same in every zone (#84). Searches find occurrences that lie past one-off
exceptdates (#88). - Invalid schedules fail at parse time. Once a schedule parses, evaluating it cannot fail (#85).
- Exact cron conversion.
fromCronandtoCrongive a schedule that fires at the same local times on the same dates, or they fail with a clear message (#95). - One error per mistake. Every lex and parse error has the same kind, message, span and suggestion in every language, and spans count Unicode code points (#96).
- Timestamps are instants. Timestamp arguments are read as instants, and results come back in the schedule's zone, or in UTC when the schedule has none (#98).
- Immutable schedules with getters and equality. A schedule cannot change after it is built. It has six read-only getters, and two schedules are equal when their parts are equal (#99, #101).
- Faster. All nine evaluators share one search design. In a stress set of DST cases, total evaluation time fell in every language, for example Go from 30s to 14s and Java from 4.9s to 2.5s (#89, #90, #91, #92).
- New: Swift. A native Swift package with no dependencies, installed with SwiftPM (#102).
- New home. The repository is now
github.com/simpllyf/hron, and the Go module path is nowgithub.com/simpllyf/hron/go/v2(#94).
Breaking changes by package
Changes that apply to every package are described under Behaviour changes: stricter parsing (#85, #96), new error message text (#95, #96), and some fromCron results written in a different form (#95).
Rust: hron (crates.io)
Schedule::from_parts(ScheduleParts) -> Resultandto_parts()replaceSchedule::newand thewith_timezone,with_except,with_until,with_anchorandwith_duringmethods. Parts are checked with the same rules asparse, and a bad part fails with anevalerror (#99).- Evaluation can no longer fail, so
next_from,previous_from,next_n_fromandmatchesreturn their values directly instead of aResult, and theoccurrencesandbetweeniterators yieldZoned(#99). Schedule::expr()is renamed toexpression(), andanchor()is renamed tostarting()(#101).- Error spans count Unicode code points, not bytes.
Spankeeps its type, so code that slices the input with a span must convert it. The README shows how (#96).
Also new: Hash is implemented on Schedule and on every part type. ErrorKind is new, and ScheduleError gains the accessors kind(), message(), span(), input() and suggestion(). ErrorKind and Span are re-exported at the crate root (#101).
CLI: hron-cli (crates.io and release binaries)
--nis replaced by-n/--count(#101).--fromwithout-nprints one occurrence, not up to 100 (#101).- A usage error exits with status 2 (#98).
-ntogether with--tois now a usage error. So is--explainor--from-crontogether with an expression, or the two together (#101).
Also changed: --from and --to take the same RFC 9557 or RFC 3339 strings as WebAssembly, with an offset or Z, and negative years are accepted (#98). --json prints [] when nothing is found (#101). Without --to, an empty result now prints the note "no occurrences after --from" (#101). Piping the output to head no longer panics (#98).
WebAssembly: hron-wasm (npm)
- A timestamp string without an offset is rejected. Strings may be RFC 9557 or RFC 3339, with an offset or
Z, includingDate.prototype.toISOString()output (#98). - An argument of the wrong type throws
TypeError, and a bad value throwsRangeError. Both used to trap withRuntimeError: memory access out of bounds(#98). - An
norlimitof 0 or less returns[], where -1 used to wrap to 4294967295. A non-integer such as1.5throws (#98). toJSON()returns a plain object, where it used to return aMapthat serialized as{}. Absent fields arenull(#98).timezone,nextFromandpreviousFromreturnnullinstead ofundefined(#101).- A non-string input to
parse,validate,fromCronorexplainCronthrowsTypeError. A number used to trap (#101).
Also new: explainCron is exported, as its README already showed (#95). Errors carry kind (#95), plus span, suggestion, input and displayRich() (#96). New getters return the same plain objects as hron-ts, frozen at every level, and there is a new equals(other). The .d.ts now types the part types, HronError, HronErrorKind and Span (#101). Array returns are typed string[] (#98).
TypeScript: hron-ts (npm)
- Timestamp arguments are checked when the method is called.
Temporal.ZonedDateTimeandTemporal.Instantare accepted, native or polyfilled, and anything else is aTypeError(#98). - A non-integer
nis aRangeError.nextNFrom(now, 1.5)used to run to the end of the supported range (#98). occurrencesandbetweenreject bad arguments when they are called, not on the firstnext()(#98).expressionis deeply frozen, and the AST types arereadonly(#99).new Schedule(...)called from JavaScript throws. Build schedules withparseorfromCron(#99).- A non-string input to
parse,validateorfromCronthrowsTypeError, andvalidateno longer returnsfalsefor one (#101). validateno longer swallows errors that are not aHronError(#101).- The
HronErrorconstructors throwTypeErrorfor a bad message, input, suggestion or span (#101). - The
ScheduleDatatype export is removed (#101). - Error spans count Unicode code points, not UTF-16 code units. The numbers change only for input that contains characters outside the Basic Multilingual Plane (#96).
Also new: the getters except, until, starting and during, which return frozen values, and equals(other). NearestDirection is now exported (#101).
Python: hron (PyPI)
ScheduleData(expr=..., anchor=...)is nowScheduleData(expression=..., starting=...)(#101).HronError.input_textis nowHronError.input(#101).- A non-
strinput toparse,validateorfrom_cronraisesTypeError(#101). HronError(...)and its constructors raiseTypeErrorfor an argument of the wrong type (#101).ScheduleDatais frozen, and its lists are tuples (#99).Schedule(ScheduleData(...))checks its parts with the same rules asparse, and a value of the wrong type is aTypeError(#99).- The internal
to_cron,display,parseand the like can no longer be imported fromhron(#99). - A timestamp argument that is not a
datetimeraisesTypeError, notAttributeError(#98). nis converted withoperator.index, so a non-integernraisesTypeError(#98).
Also new: the properties except_, until, starting and during, and HronError.message (#101). Schedule has value equality and a data property, and NearestWeekdayTarget and NearestDirection are exported (#99). OrdinalPosition.LAST.to_n() returns -1, where it used to raise KeyError (#99).
Go: github.com/simpllyf/hron/go/v2
- The module path is now
github.com/simpllyf/hron/go/v2. Released v1 versions stay available under their old path (#94). Parse,Display,FromCron,ToCronandTokenize, which took or returned rawScheduleData, are no longer exported. UseParseSchedule,String(),FromCronExprandSchedule.ToCron(#99).NewSchedulechecks every part with the same rules as parsing and copies it deeply. A bad part fails with anevalerror, and a bad timezone, which used to be aparseerror, is one of them (#99).Data()returns a copy (#99).ScheduleData.Expris nowExpression, andAnchoris nowStarting(#101).Token,TokenKindand theToken*constants are no longer exported (#101).- The package-level
OccurrencesandBetweenfunctions are no longer exported. The methods remain (#101). HronError.Spancounts Unicode code points, not bytes. It keeps its type, so code that slices the input with it must convert. The README shows how (#96).
Also new: the getter methods Expression(), Except(), Until(), Starting() and During(), which return copies, and Equal(other) (#101).
Java: io.hron:hron (Maven Central)
- The parser, lexer, evaluator, cron and display packages move to
io.hron.internal. Amodule-infoexports onlyio.hronand the read-onlyio.hron.ast(#99). ScheduleData.ofand thewith*methods are removed (#99).Schedule.data()is removed (#101).ScheduleDatahas moved to an internal package (#101).- A null input to
parse,validateorfromCron, or a null message, span or input passed to aHronExceptionfactory, throwsNullPointerException(#101). - Error spans count Unicode code points, not UTF-16 code units. The numbers change only for input that contains characters outside the Basic Multilingual Plane (#96).
Also new: the getters expression(), except(), until(), starting() and during(), and equals and hashCode (#101).
C#: Hron (NuGet)
- Apart from the part types in
Hron.Ast, onlySchedule,HronException,ErrorKind,ErrorKindExtensionsandSpanstay public. The lexer, parser, evaluator, cron converter, display andScheduleDatatypes are internal (#99, #101). Schedule.Datais removed (#99).- The part types in
Hron.Astare read-only. No part has a setter, and their lists cannot be changed through a cast (#101). FromCron(null)throwsArgumentNullException(#99).- A null input to
ParseorValidate, or a null message or input passed to aHronExceptionfactory, throwsArgumentNullException(#101). NextNFrom's parametercountis renamed ton, which matters to callers that pass it by name (#101).- Error spans count Unicode code points, not UTF-16 code units. The numbers change only for input that contains characters outside the Basic Multilingual Plane (#96).
Also new: the properties Expression, Except, Until, Starting and During. IEquatable<Schedule>, == and != are implemented, and the package now ships XML docs (#101).
Ruby: hron (RubyGems)
- Results are
Times in the schedule's zone, not UTC (#98). - A timestamp argument that is not a
TimeraisesTypeError, where it used to returnnil(#98). - An
nthat is not anIntegerraisesTypeError(#98). ScheduleDatakeywordsexpr:andanchor:are nowexpression:andstarting:(#101).Schedule.new(data)checks its parts with the same rules asparse, and keeps a deep frozen copy (#99).Hron.parse, which returned raw parts, is removed (#99).- The parser, evaluator, display and cron modules are private (#99).
Hron.tokenize,expand_day_spec,expand_month_targetand the lexer's constants are private (#101).- A non-String input to
parse,validateorfrom_cron, or a bad argument to aHronErrorfactory, raisesTypeError(#101). - The undocumented internals
Hron::EPOCH_DATE,Hron::EPOCH_MONDAY,Hron::TzResolver,Hron::EvalHelpers, andHron::Evaluator'sCYCLE_*andMIN/MAX_INSTANTconstants are no longer exposed (#90).
Also new: the getters except, until, starting and during (#101), Schedule#==, eql? and hash, and to_n(:last) returning -1 (#99).
Dart: hron (pub.dev)
package:hron/hron.dartno longer exportsScheduleDataor the helpersexpandDaySpec,expandMonthTargetandordinalSuffix.Weekday.tryParse,Weekday.fromNumberandMonthName.tryParseare removed. Build a schedule withSchedule.parseorSchedule.fromCron(#99, #101).- The lists in a schedule's parts are unmodifiable, so changing one throws
UnsupportedError, and a schedule cannot change after it is built (#99, #101). Scheduleis afinal class, so another library can no longer implement or extend it, for example as a mock (#101).- The package requires
timezone^0.11.1 (#98). - Error spans count Unicode code points, not UTF-16 code units. The numbers change only for input that contains characters outside the Basic Multilingual Plane (#96).
Also new: the part types are exported read-only. Schedule has the getters except, until, starting and during beside timezone and expression, and Schedule and every part type have == and hashCode. OrdinalPosition.toN returns -1 for last instead of throwing (#101).
Behaviour changes that are not API breaks
Evaluation
startingis now a lower bound as well as the interval anchor, in every method (#84).- DST is handled the same way in every zone, including transitions at midnight and skipped days. In a fall-back, an occurrence takes the first pass. In a spring-forward gap, a fixed time shifts by the length of the gap, so 02:30 becomes 03:30, and interval slots that fall in the gap are skipped (#83, #84).
- An occurrence moved by DST keeps its scheduled date for the day filter and every clause (#84).
- Results outside the supported range are null, and inputs outside it return null or empty instead of raising (#84).
- The occurrence iterators return every occurrence (#84). The documentation in every language now says that
next,nextNandoccurrencesreturn times strictly afternow. Dart's documentation used to say "at or after" (#83). matchesignores seconds, andpreviousFrommirrorsnextFrom(#84).- Fixed in one or more languages: occurrences one minute apart being skipped; fall-back resolving to the second pass (Go, Dart); Sydney-type gaps dropped (Ruby); comparisons on wall-clock times instead of instants (Python); gaps not caused by DST resolved wrongly (C#); errors in
previousFrom; yearly searches giving up after 8 years; and hangs (Go at the end of a leap year, Java and C# on huge intervals, TypeScript on close transitions) (#84). - A search finds an occurrence that lies past one or more one-off ISO
exceptdates. For example,every 400 years on jan 1 at 00:00 except 2400-01-01 starting 2000-01-01next fires in 2800 (#88). - Go: a search near the end of the supported range no longer stops at UTC midnight on 9999-12-30, so it finds occurrences that are still in range in UTC+14 zones (#91).
- Python: a backward search by week near the year 9999 no longer ends with nothing (#91).
- Python:
matchesgives the right answer in historic gaps shorter than a minute (#92). - Dart: evaluation works when compiled to JavaScript. Before, every
nextFromthrew there (#90). - Dart: results for a schedule without a zone stay in
UTCundertimezone0.11.1, which renamed its UTC location toEtc/UTC(#78). - C#: .NET keeps UTC offsets in whole minutes. In historic zones whose offset has seconds, such as
Africa/Monroviabefore 1972, occurrences can be up to a minute off. The spec and the C# README now state this exactly (#97, #101).
Timestamps and counts
now,from,toanddatetimeeach identify an instant, and only the instant matters. Every result is in the schedule's timezone, or in UTC when the schedule has none. No method modifies its arguments (#98).nextNFromreturns nothing for annof 0 or less, and every occurrence for a hugen, without reserving room for it (#98).- A usage error is the platform's own argument error, never a hron error (#98, #101).
- Ruby: the
Timeyou pass is no longer modified, and a frozenTimeno longer raisesFrozenError(#98). - Python: a naive
datetime.minordatetime.maxreturns nothing instead of raising.occurrencesandbetweenreject bad arguments when called, not on the firstnext()(#98). - Java: the supported range is checked before zones are converted, so
LocalDateTime.MINandMAXno longer throwDateTimeException(#98). - Go: period arithmetic is 64-bit. On 32-bit platforms,
every 306783379 weeks on monday at 09:00used to fire on a Tuesday (#98).
Validation at parse time
Every invalid schedule is rejected when it is parsed, in every language, and validate returns false for it (#85):
- A named
untilwithout a year, such asuntil dec 31, is an error unless the schedule hasstarting. Withstarting S, it means the first such date on or after S. Before, it was re-resolved againstnow, so it rolled forward every year and never ended (#85). - A time window that crosses midnight, such as
from 17:00 to 09:00, is an error. Before, it parsed and never fired (#85). - Timezone names are matched in any case and displayed in their IANA form (
in utcbecomesUTC, andin america/new_yorkbecomesAmerica/New_York). Links keep their own name, for exampleUS/Eastern(#85). - Abbreviations and offsets such as
EST,Zand+05:30are errors. So are unknown names, non-ASCII names, and names underSystemV/,posix/orright/(#85). - Year
0000in an ISO date is an error (#85). - A number above 2147483647 is the lex error
number must be at most 2147483647(#85, #96). - These are now rejected:
every 9:5 days, which used to becomeevery 9 days; Unicode digits such as٩; a form feed used as whitespace; and a duplicateinclause (#96). - How zone names are found differs by platform, and each package's README describes it. Go indexes
$ZONEINFO, the system zoneinfo and GOROOT's zip. C# on Windows needs names in their exact capitalization. Dart needslatest_all.dartloaded for link names. TypeScript follows the JS engine's Intl data (#85). - C#: ISO dates are parsed the same under every culture. Under
th-TH,starting 2026-03-01used to become the year 1483 (#85). - Ruby: ISO dates before 1582 are checked on the proleptic Gregorian calendar, not the Julian one (#96).
- Rust: the lexer no longer panics on a multi-byte character after a number (#75).
Cron conversion
fromCron and toCron now convert exactly or fail. Exact means the result fires at the same local times on the same dates. The timezone and DST transitions are outside that promise, because cron schedulers handle them differently (#95).
toCronnow converts yearly dates (0 0 25 12 *), ordinal and last weekdays (0 10 * * 1#1,0 16 * * 5L), last day and last weekday (L,LW), several times a day (0 9,17 * * *), windows within a day (*/30 9-17 * * *), andduring(0 9 * 1,6 1-5) (#95).fromCronnow fails on a cron that restricts both day fields, such as0 9 15 * 1, because cron fires on either day and no hron schedule expresses that (#95).fromCronnow fails on more than 24 times a day that are not evenly spaced, such as*/7 * * * *. Before, that becameevery 7 min from 00:00 to 23:59, which does not restart at :00 each hour as cron does (#95).- Some
fromCronresults are written differently.0 9 1 3 *is nowevery year on mar 1 at 09:00, and*/15 9-17 * * *ends at 17:45, where it used to stop at 17:00 (#95). - Rust:
explain_cron(c)isfrom_cron(c)?.to_string()(#95). - The accepted cron syntax, the conversion rules and one error message for each failure are defined in the spec's "Cron Conversion" section (#95).
Error messages and spans
- Every lex, parse and cron error message has new text, identical in every language. For example:
day must be 1-29 for feb, got 30,time must be 00:00-23:59, got 25:00, andtime window must not run backwards: 17:00 to 09:00 (a window cannot cross midnight). Code that matches on message text should match on the error kind instead (#95, #96). - Spans count Unicode code points in every language (#96).
- Characters outside printable ASCII are shown as
U+00A0and the like, not as mojibake or half a surrogate pair (#96). displayRichshows tabs and line breaks in the input as spaces, so the carets stay aligned, and joins lines with\non every platform (#96).
Schedules built in code
- In Rust, Go, Python and Ruby, a schedule built from parts keeps every promise a parsed one makes. Evaluating it never fails, its display parses back to the same schedule, and
toCronis exact. Before, a bad part could panic or divide by zero,toCroncould write0 25 * * *, and display could write text that does not parse (#99). - Java, C#, TypeScript and Dart build schedules only through
parseandfromCron(#99). - In every language, a schedule cannot be changed after it is built (#99).
New: Swift
hron now has a native Swift package, the ninth implementation. It uses only the standard library and Foundation (#102).
.package(url: "https://github.com/simpllyf/hron", from: "2.0.0")
// target dependency: .product(name: "Hron", package: "hron")- SwiftPM resolves the repository's
v2.0.0tag, so there is no registry release (#102). - It supports iOS 15, macOS 12, tvOS 15, watchOS 9, visionOS 1 and Linux. It needs swift-tools-version 6.0 and builds in Swift 6 language mode with strict concurrency (#102).
- The API uses
Schedule.parse(_:),validate(_:)andfromCron(_:), along withnext(after:),next(_:after:),previous(before:)andmatches(_:).occurrences(after:)andoccurrences(after:through:)return lazy sequences ofDate.toCron()converts to cron, anddescriptiongives the expression text (#102). - Timestamps are
Datein andDateout.schedule.timeZonegives the zone to format results in, andtimeZoneIdentifiergives the IANA name (#102). - Throwing functions throw
HronErroras a typed throw.Scheduleand its parts are immutableHashable,Sendablevalue types, equal when their parts are equal (#102). - Zone names match in any case and display in their IANA form on every platform. The package carries the IANA zone and link names from tzdata 2026e, because Foundation matches names only in their exact case and lists no links (#102).
- Civil dates use proleptic Gregorian arithmetic, because Foundation's Gregorian calendar switches to Julian before 1582 (#102).
- Values that can exceed 2³¹ are
Int64, so the package is correct on 32-bit Apple Watch models (#102). - Enums that may gain cases are marked
@nonexhaustivewhere the compiler supports it, so aswitchover one needs@unknown default(#102).
Upgrading
Renamed names
| Package | 1.x | 2.0 |
|---|---|---|
| Rust | Schedule::expr(), anchor() |
expression(), starting() (#101) |
| Rust | Schedule::new(expr).with_timezone(..)… |
Schedule::from_parts(ScheduleParts { .. })? (#99) |
| Python | ScheduleData(expr=..., anchor=...) |
ScheduleData(expression=..., starting=...) (#101) |
| Python | HronError.input_text |
HronError.input (#101) |
| Go | import "…/hron/go" |
import "github.com/simpllyf/hron/go/v2" (#94) |
| Go | ScheduleData.Expr, .Anchor |
.Expression, .Starting (#101) |
| Go | Parse, Display, FromCron, ToCron |
ParseSchedule, String(), FromCronExpr, Schedule.ToCron (#99) |
| Ruby | ScheduleData.new(expr:, anchor:) |
expression:, starting: (#101) |
| Java | schedule.data() |
expression(), except(), until(), starting(), during() (#101) |
| C# | schedule.Data |
Expression, Except, Until, Starting, During (#99, #101) |
| C# | NextNFrom(now, count: 5) |
NextNFrom(now, n: 5) (#101) |
| CLI | --n 5 |
-n 5 or --count 5 (#101) |
Null and wrong-type inputs
Passing null or a value of the wrong type is now the platform's usage error, not a hron error, and validate raises it instead of returning false (#98, #101):
| Package | Error |
|---|---|
| TypeScript, WebAssembly | TypeError (and RangeError for a bad value) |
| Python, Ruby | TypeError |
| Java | NullPointerException |
| C# | ArgumentNullException |
Code that caught a hron error around these calls should validate its input first.
Timestamps
- Rust: evaluation returns values directly, so drop the
?orunwrap()afternext_from,previous_from,next_n_fromandmatches(#99). - Ruby: results are now in the schedule's zone. If you relied on UTC, call
.utcon the result (#98). - WebAssembly and CLI: timestamp strings need an offset or
Z.new Date().toISOString()works (#98). - TypeScript: pass a
Temporal.ZonedDateTimeorTemporal.Instant. Any other value throwsTypeError(#98). - All: results come back in the schedule's zone whatever zone
nowis in, so convert them if you need another zone (#98).
Schedules that no longer parse
- Add a year or a
startingdate to a nameduntil:until 2026-12-31, oruntil dec 31 starting 2026-01-01(#85). - Split a window that crosses midnight into two schedules (#85).
- Replace abbreviations and offsets with an IANA name, for example
in America/New_Yorkinstead ofin EST(#85). fromCronnow rejects crons that restrict both day fields and crons with more than 24 uneven times a day. Convert those by hand (#95).
Requirements
The minimum versions are Rust 1.93 (#79), Go 1.25, Java 25, Ruby 4.0 and the Dart SDK ^3.11 (#81), Python 3.11 (#82), and swift-tools-version 6.0 (#102). Dart also needs timezone ^0.11.1 (#98).
Also in this release
- Comments throughout the codebase were pruned to what earns its place, and documentation that was wrong was corrected. Dart's published example now runs (#83, #93).
- The site at hron.io has a new design and a playground built on hron-wasm (#76, #77, #95).
- Evaluation is faster in every language (#89, #90, #91, #92).
- A cross-implementation differential tool,
just diff, checks that all implementations agree (#86, #87, #97). - Toolchains, CI actions and dependencies were updated, and hron-wasm bundles newer tzdata (#78, #79, #80, #81, #82).