cron: fix step offset matching, replace AST with bitmask

While reviewing !93 (merged) (which fixed wrap-around range matching in cron expressions), I (and Claude) took a closer look at the cron package and found a second bug in expression matching, along with broader test gaps.

Step expressions used global divisibility (v % step == 0) instead of checking offset from the range start. This meant 1/3 in minutes matched nothing at all (the only candidate, minute 1, fails 1 % 3 == 0), and 5-20/3 matched minutes 6, 9, 12, 15, 18 instead of the correct 5, 8, 11, 14, 17, 20.

This went undetected because the test suite (ported from Cloudflare's saffron) only covered cases with zero-aligned starts where the modulo happened to produce correct results.

To fix this, the runtime AST matching (Expr interface with Many, Range, Step, All, Value types) has been replaced with a bitmask approach: each cron field is precomputed into a uint64 at parse time, where bit N being set means value N is included. Ranges and steps are resolved by iterating and setting bits directly, so there is no runtime matching logic that can get step offset or wrap-around wrong. Contains() becomes a single bit-check per field.

What changed

  • expression.go: Replaced the Expr interface and its implementing types with a bitfield type. Contains() checks bits instead of walking an AST. String() returns the original input.
  • parser.go: Produces bitmasks directly via a single setStep function that handles values, ranges, and steps (including wrap-around) by iterating and setting bits. Used strings.Cut to simplify the parsing flow.
  • parser_test.go: Rewritten to verify bitmask content rather than AST structure.
  • expression_test.go: Added test coverage for non-aligned steps, wrap-around range+step combinations, and Sunday DOW edge cases.

Merge request reports

Loading
Loading