Skip to content

v2.0.0

Choose a tag to compare

@prasrvenkat prasrvenkat released this 02 Oct 04:36
· 15 commits to main since this release
Immutable release. Only release title and notes can be modified.
e28b127

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.json gained hundreds of fixed-answer cases and a set of invariants (#84). spec/build.json covers schedules built in code (#99), and spec/api.json covers 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 except dates (#88).
  • Invalid schedules fail at parse time. Once a schedule parses, evaluating it cannot fail (#85).
  • Exact cron conversion. fromCron and toCron give 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 now github.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) -> Result and to_parts() replace Schedule::new and the with_timezone, with_except, with_until, with_anchor and with_during methods. Parts are checked with the same rules as parse, and a bad part fails with an eval error (#99).
  • Evaluation can no longer fail, so next_from, previous_from, next_n_from and matches return their values directly instead of a Result, and the occurrences and between iterators yield Zoned (#99).
  • Schedule::expr() is renamed to expression(), and anchor() is renamed to starting() (#101).
  • Error spans count Unicode code points, not bytes. Span keeps 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)

  • --n is replaced by -n/--count (#101).
  • --from without -n prints one occurrence, not up to 100 (#101).
  • A usage error exits with status 2 (#98).
  • -n together with --to is now a usage error. So is --explain or --from-cron together 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, including Date.prototype.toISOString() output (#98).
  • An argument of the wrong type throws TypeError, and a bad value throws RangeError. Both used to trap with RuntimeError: memory access out of bounds (#98).
  • An n or limit of 0 or less returns [], where -1 used to wrap to 4294967295. A non-integer such as 1.5 throws (#98).
  • toJSON() returns a plain object, where it used to return a Map that serialized as {}. Absent fields are null (#98).
  • timezone, nextFrom and previousFrom return null instead of undefined (#101).
  • A non-string input to parse, validate, fromCron or explainCron throws TypeError. 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.ZonedDateTime and Temporal.Instant are accepted, native or polyfilled, and anything else is a TypeError (#98).
  • A non-integer n is a RangeError. nextNFrom(now, 1.5) used to run to the end of the supported range (#98).
  • occurrences and between reject bad arguments when they are called, not on the first next() (#98).
  • expression is deeply frozen, and the AST types are readonly (#99).
  • new Schedule(...) called from JavaScript throws. Build schedules with parse or fromCron (#99).
  • A non-string input to parse, validate or fromCron throws TypeError, and validate no longer returns false for one (#101).
  • validate no longer swallows errors that are not a HronError (#101).
  • The HronError constructors throw TypeError for a bad message, input, suggestion or span (#101).
  • The ScheduleData type 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 now ScheduleData(expression=..., starting=...) (#101).
  • HronError.input_text is now HronError.input (#101).
  • A non-str input to parse, validate or from_cron raises TypeError (#101).
  • HronError(...) and its constructors raise TypeError for an argument of the wrong type (#101).
  • ScheduleData is frozen, and its lists are tuples (#99).
  • Schedule(ScheduleData(...)) checks its parts with the same rules as parse, and a value of the wrong type is a TypeError (#99).
  • The internal to_cron, display, parse and the like can no longer be imported from hron (#99).
  • A timestamp argument that is not a datetime raises TypeError, not AttributeError (#98).
  • n is converted with operator.index, so a non-integer n raises TypeError (#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, ToCron and Tokenize, which took or returned raw ScheduleData, are no longer exported. Use ParseSchedule, String(), FromCronExpr and Schedule.ToCron (#99).
  • NewSchedule checks every part with the same rules as parsing and copies it deeply. A bad part fails with an eval error, and a bad timezone, which used to be a parse error, is one of them (#99).
  • Data() returns a copy (#99).
  • ScheduleData.Expr is now Expression, and Anchor is now Starting (#101).
  • Token, TokenKind and the Token* constants are no longer exported (#101).
  • The package-level Occurrences and Between functions are no longer exported. The methods remain (#101).
  • HronError.Span counts 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. A module-info exports only io.hron and the read-only io.hron.ast (#99).
  • ScheduleData.of and the with* methods are removed (#99).
  • Schedule.data() is removed (#101).
  • ScheduleData has moved to an internal package (#101).
  • A null input to parse, validate or fromCron, or a null message, span or input passed to a HronException factory, throws NullPointerException (#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, only Schedule, HronException, ErrorKind, ErrorKindExtensions and Span stay public. The lexer, parser, evaluator, cron converter, display and ScheduleData types are internal (#99, #101).
  • Schedule.Data is removed (#99).
  • The part types in Hron.Ast are read-only. No part has a setter, and their lists cannot be changed through a cast (#101).
  • FromCron(null) throws ArgumentNullException (#99).
  • A null input to Parse or Validate, or a null message or input passed to a HronException factory, throws ArgumentNullException (#101).
  • NextNFrom's parameter count is renamed to n, 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 Time raises TypeError, where it used to return nil (#98).
  • An n that is not an Integer raises TypeError (#98).
  • ScheduleData keywords expr: and anchor: are now expression: and starting: (#101).
  • Schedule.new(data) checks its parts with the same rules as parse, 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_target and the lexer's constants are private (#101).
  • A non-String input to parse, validate or from_cron, or a bad argument to a HronError factory, raises TypeError (#101).
  • The undocumented internals Hron::EPOCH_DATE, Hron::EPOCH_MONDAY, Hron::TzResolver, Hron::EvalHelpers, and Hron::Evaluator's CYCLE_* and MIN/MAX_INSTANT constants 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.dart no longer exports ScheduleData or the helpers expandDaySpec, expandMonthTarget and ordinalSuffix. Weekday.tryParse, Weekday.fromNumber and MonthName.tryParse are removed. Build a schedule with Schedule.parse or Schedule.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).
  • Schedule is a final 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

  • starting is 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, nextN and occurrences return times strictly after now. Dart's documentation used to say "at or after" (#83).
  • matches ignores seconds, and previousFrom mirrors nextFrom (#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 except dates. For example, every 400 years on jan 1 at 00:00 except 2400-01-01 starting 2000-01-01 next 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: matches gives the right answer in historic gaps shorter than a minute (#92).
  • Dart: evaluation works when compiled to JavaScript. Before, every nextFrom threw there (#90).
  • Dart: results for a schedule without a zone stay in UTC under timezone 0.11.1, which renamed its UTC location to Etc/UTC (#78).
  • C#: .NET keeps UTC offsets in whole minutes. In historic zones whose offset has seconds, such as Africa/Monrovia before 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, to and datetime each 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).
  • nextNFrom returns nothing for an n of 0 or less, and every occurrence for a huge n, without reserving room for it (#98).
  • A usage error is the platform's own argument error, never a hron error (#98, #101).
  • Ruby: the Time you pass is no longer modified, and a frozen Time no longer raises FrozenError (#98).
  • Python: a naive datetime.min or datetime.max returns nothing instead of raising. occurrences and between reject bad arguments when called, not on the first next() (#98).
  • Java: the supported range is checked before zones are converted, so LocalDateTime.MIN and MAX no longer throw DateTimeException (#98).
  • Go: period arithmetic is 64-bit. On 32-bit platforms, every 306783379 weeks on monday at 09:00 used 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 until without a year, such as until dec 31, is an error unless the schedule has starting. With starting S, it means the first such date on or after S. Before, it was re-resolved against now, 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 utc becomes UTC, and in america/new_york becomes America/New_York). Links keep their own name, for example US/Eastern (#85).
  • Abbreviations and offsets such as EST, Z and +05:30 are errors. So are unknown names, non-ASCII names, and names under SystemV/, posix/ or right/ (#85).
  • Year 0000 in 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 become every 9 days; Unicode digits such as ٩; a form feed used as whitespace; and a duplicate in clause (#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 needs latest_all.dart loaded 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-01 used 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).

  • toCron now 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 * * *), and during (0 9 * 1,6 1-5) (#95).
  • fromCron now fails on a cron that restricts both day fields, such as 0 9 15 * 1, because cron fires on either day and no hron schedule expresses that (#95).
  • fromCron now fails on more than 24 times a day that are not evenly spaced, such as */7 * * * *. Before, that became every 7 min from 00:00 to 23:59, which does not restart at :00 each hour as cron does (#95).
  • Some fromCron results are written differently. 0 9 1 3 * is now every 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) is from_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, and time 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+00A0 and the like, not as mojibake or half a surrogate pair (#96).
  • displayRich shows tabs and line breaks in the input as spaces, so the carets stay aligned, and joins lines with \n on 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 toCron is exact. Before, a bad part could panic or divide by zero, toCron could write 0 25 * * *, and display could write text that does not parse (#99).
  • Java, C#, TypeScript and Dart build schedules only through parse and fromCron (#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.0 tag, 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(_:) and fromCron(_:), along with next(after:), next(_:after:), previous(before:) and matches(_:). occurrences(after:) and occurrences(after:through:) return lazy sequences of Date. toCron() converts to cron, and description gives the expression text (#102).
  • Timestamps are Date in and Date out. schedule.timeZone gives the zone to format results in, and timeZoneIdentifier gives the IANA name (#102).
  • Throwing functions throw HronError as a typed throw. Schedule and its parts are immutable Hashable, Sendable value 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 @nonexhaustive where the compiler supports it, so a switch over 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 ? or unwrap() after next_from, previous_from, next_n_from and matches (#99).
  • Ruby: results are now in the schedule's zone. If you relied on UTC, call .utc on the result (#98).
  • WebAssembly and CLI: timestamp strings need an offset or Z. new Date().toISOString() works (#98).
  • TypeScript: pass a Temporal.ZonedDateTime or Temporal.Instant. Any other value throws TypeError (#98).
  • All: results come back in the schedule's zone whatever zone now is in, so convert them if you need another zone (#98).

Schedules that no longer parse

  • Add a year or a starting date to a named until: until 2026-12-31, or until 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_York instead of in EST (#85).
  • fromCron now 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).