| 1 | package chronos |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "strconv" |
| 6 | "strings" |
| 7 | "time" |
| 8 | ) |
| 9 | |
| 10 | // cronSchedule is a parsed cron expression matched against wall-clock |
| 11 | // fields. Each field is a bitset whose set bits are the permitted values |
| 12 | // for that field; [cronSchedule.Next] advances to the next instant whose |
| 13 | // fields all match. It is the [Schedule] backing [parseSpec] for |
| 14 | // calendar specs (the @every form reuses [everySchedule]). |
| 15 | type cronSchedule struct { |
| 16 | sec uint64 // 0-59 (only consulted when hasSec) |
| 17 | min uint64 // 0-59 |
| 18 | hour uint64 // 0-23 |
| 19 | dom uint64 // 1-31 |
| 20 | month uint64 // 1-12 |
| 21 | dow uint64 // 0-6, Sunday=0 |
| 22 | hasSec bool // true for 6-field specs with a leading seconds field |
| 23 | } |
| 24 | |
| 25 | // field describes the value domain of a single cron field for the parser. |
| 26 | type field struct { |
| 27 | min, max int |
| 28 | names map[string]int // optional symbolic aliases (months, weekdays) |
| 29 | } |
| 30 | |
| 31 | var ( |
| 32 | monthNames = map[string]int{ |
| 33 | "jan": 1, "feb": 2, "mar": 3, "apr": 4, "may": 5, "jun": 6, |
| 34 | "jul": 7, "aug": 8, "sep": 9, "oct": 10, "nov": 11, "dec": 12, |
| 35 | } |
| 36 | dowNames = map[string]int{ |
| 37 | "sun": 0, "mon": 1, "tue": 2, "wed": 3, "thu": 4, "fri": 5, "sat": 6, |
| 38 | } |
| 39 | ) |
| 40 | |
| 41 | // macros maps the @-shorthands to their 5-field equivalents. |
| 42 | var macros = map[string]string{ |
| 43 | "@hourly": "0 * * * *", |
| 44 | "@daily": "0 0 * * *", |
| 45 | "@weekly": "0 0 * * 0", |
| 46 | "@monthly": "0 0 1 * *", |
| 47 | "@yearly": "0 0 1 1 *", |
| 48 | } |
| 49 | |
| 50 | // parseSpec parses a cron spec into a [Schedule]. It accepts the @every |
| 51 | // duration form (reusing [everySchedule]), the @-macros, and 5- or |
| 52 | // 6-field cron expressions. It fails fast on any malformed input (PC1): |
| 53 | // no field is ever silently normalized to a default. |
| 54 | func parseSpec(spec string) (Schedule, error) { |
| 55 | spec = strings.TrimSpace(spec) |
| 56 | if spec == "" { |
| 57 | return nil, fmt.Errorf("chronos: empty cron spec") |
| 58 | } |
| 59 | |
| 60 | if strings.HasPrefix(spec, "@every ") { |
| 61 | durStr := strings.TrimSpace(strings.TrimPrefix(spec, "@every ")) |
| 62 | d, err := time.ParseDuration(durStr) |
| 63 | if err != nil { |
| 64 | return nil, fmt.Errorf("chronos: @every duration %q: %w", durStr, err) |
| 65 | } |
| 66 | if d <= 0 { |
| 67 | return nil, fmt.Errorf("chronos: @every duration %q must be positive", durStr) |
| 68 | } |
| 69 | return everySchedule{span: Span{Dur: d}}, nil |
| 70 | } |
| 71 | |
| 72 | if strings.HasPrefix(spec, "@") { |
| 73 | expanded, ok := macros[spec] |
| 74 | if !ok { |
| 75 | return nil, fmt.Errorf("chronos: unknown macro %q", spec) |
| 76 | } |
| 77 | spec = expanded |
| 78 | } |
| 79 | |
| 80 | return parseFields(spec) |
| 81 | } |
| 82 | |
| 83 | // parseFields parses a 5- or 6-field cron expression into a cronSchedule. |
| 84 | func parseFields(spec string) (Schedule, error) { |
| 85 | fields := strings.Fields(spec) |
| 86 | var hasSec bool |
| 87 | switch len(fields) { |
| 88 | case 5: |
| 89 | hasSec = false |
| 90 | case 6: |
| 91 | hasSec = true |
| 92 | default: |
| 93 | return nil, fmt.Errorf("chronos: cron spec %q: want 5 or 6 fields, got %d", spec, len(fields)) |
| 94 | } |
| 95 | |
| 96 | cs := cronSchedule{hasSec: hasSec} |
| 97 | idx := 0 |
| 98 | if hasSec { |
| 99 | sec, err := parseField(fields[idx], field{min: 0, max: 59}) |
| 100 | if err != nil { |
| 101 | return nil, fmt.Errorf("chronos: seconds field: %w", err) |
| 102 | } |
| 103 | cs.sec = sec |
| 104 | idx++ |
| 105 | } |
| 106 | |
| 107 | parsers := []struct { |
| 108 | name string |
| 109 | f field |
| 110 | dst *uint64 |
| 111 | }{ |
| 112 | {"minutes", field{min: 0, max: 59}, &cs.min}, |
| 113 | {"hours", field{min: 0, max: 23}, &cs.hour}, |
| 114 | {"day-of-month", field{min: 1, max: 31}, &cs.dom}, |
| 115 | {"month", field{min: 1, max: 12, names: monthNames}, &cs.month}, |
| 116 | {"day-of-week", field{min: 0, max: 7, names: dowNames}, &cs.dow}, |
| 117 | } |
| 118 | for _, p := range parsers { |
| 119 | v, err := parseField(fields[idx], p.f) |
| 120 | if err != nil { |
| 121 | return nil, fmt.Errorf("chronos: %s field: %w", p.name, err) |
| 122 | } |
| 123 | *p.dst = v |
| 124 | idx++ |
| 125 | } |
| 126 | |
| 127 | // Normalize dow: 7 means Sunday, fold onto bit 0. |
| 128 | if cs.dow&(1<<7) != 0 { |
| 129 | cs.dow = (cs.dow &^ (1 << 7)) | (1 << 0) |
| 130 | } |
| 131 | |
| 132 | return cs, nil |
| 133 | } |
| 134 | |
| 135 | // parseField parses one cron field into a bitset over [f.min, f.max]. |
| 136 | // It handles "*", comma lists, "a-b" ranges, and "*/n" / "a-b/n" steps, |
| 137 | // rejecting empty elements, out-of-range values, and bad tokens (PC1). |
| 138 | func parseField(s string, f field) (uint64, error) { |
| 139 | if s == "" { |
| 140 | return 0, fmt.Errorf("empty field") |
| 141 | } |
| 142 | var bits uint64 |
| 143 | for _, part := range strings.Split(s, ",") { |
| 144 | if part == "" { |
| 145 | return 0, fmt.Errorf("empty list element in %q", s) |
| 146 | } |
| 147 | b, err := parseElement(part, f) |
| 148 | if err != nil { |
| 149 | return 0, err |
| 150 | } |
| 151 | bits |= b |
| 152 | } |
| 153 | return bits, nil |
| 154 | } |
| 155 | |
| 156 | // parseElement parses a single comma-separated element of a field. |
| 157 | func parseElement(part string, f field) (uint64, error) { |
| 158 | rangePart := part |
| 159 | step := 1 |
| 160 | if slash := strings.IndexByte(part, '/'); slash >= 0 { |
| 161 | rangePart = part[:slash] |
| 162 | stepStr := part[slash+1:] |
| 163 | n, err := strconv.Atoi(stepStr) |
| 164 | if err != nil { |
| 165 | return 0, fmt.Errorf("bad step %q in %q", stepStr, part) |
| 166 | } |
| 167 | if n < 1 { |
| 168 | return 0, fmt.Errorf("step must be >= 1 in %q", part) |
| 169 | } |
| 170 | step = n |
| 171 | } |
| 172 | |
| 173 | var lo, hi int |
| 174 | switch { |
| 175 | case rangePart == "*": |
| 176 | lo, hi = f.min, f.max |
| 177 | case strings.ContainsRune(rangePart, '-'): |
| 178 | dash := strings.IndexByte(rangePart, '-') |
| 179 | a, err := parseValue(rangePart[:dash], f) |
| 180 | if err != nil { |
| 181 | return 0, err |
| 182 | } |
| 183 | b, err := parseValue(rangePart[dash+1:], f) |
| 184 | if err != nil { |
| 185 | return 0, err |
| 186 | } |
| 187 | if a > b { |
| 188 | return 0, fmt.Errorf("descending range %q", rangePart) |
| 189 | } |
| 190 | lo, hi = a, b |
| 191 | default: |
| 192 | v, err := parseValue(rangePart, f) |
| 193 | if err != nil { |
| 194 | return 0, err |
| 195 | } |
| 196 | lo, hi = v, v |
| 197 | } |
| 198 | |
| 199 | var bits uint64 |
| 200 | for v := lo; v <= hi; v += step { |
| 201 | bits |= 1 << uint(v) |
| 202 | } |
| 203 | return bits, nil |
| 204 | } |
| 205 | |
| 206 | // parseValue parses a single numeric or symbolic value, validating it |
| 207 | // against the field's [f.min, f.max] domain. |
| 208 | func parseValue(s string, f field) (int, error) { |
| 209 | if f.names != nil { |
| 210 | if v, ok := f.names[strings.ToLower(s)]; ok { |
| 211 | return v, nil |
| 212 | } |
| 213 | } |
| 214 | v, err := strconv.Atoi(s) |
| 215 | if err != nil { |
| 216 | return 0, fmt.Errorf("bad value %q", s) |
| 217 | } |
| 218 | if v < f.min || v > f.max { |
| 219 | return 0, fmt.Errorf("value %d out of range [%d,%d]", v, f.min, f.max) |
| 220 | } |
| 221 | return v, nil |
| 222 | } |
| 223 | |
| 224 | // maxLookahead caps Next's search: an unsatisfiable spec (e.g. Feb 30) |
| 225 | // yields the zero time rather than looping forever (IV1). |
| 226 | const maxLookahead = 5 * 366 * 24 * time.Hour |
| 227 | |
| 228 | // Next returns the next instant strictly after t that matches every cron |
| 229 | // field, or the zero [time.Time] if none occurs within ~5 years (IV1). |
| 230 | // It operates entirely in t's location (IV4), relying on [time.Date] to |
| 231 | // normalize DST gaps and repeats rather than computing offsets by hand. |
| 232 | func (s cronSchedule) Next(t time.Time) time.Time { |
| 233 | loc := t.Location() |
| 234 | var cur time.Time |
| 235 | if s.hasSec { |
| 236 | cur = t.Truncate(time.Second).Add(time.Second) |
| 237 | } else { |
| 238 | // Round up to the next whole minute strictly after t. |
| 239 | cur = t.Truncate(time.Minute).Add(time.Minute) |
| 240 | } |
| 241 | // Rebuild via time.Date so DST normalization is well-defined. |
| 242 | cur = s.rebuild(cur, loc) |
| 243 | |
| 244 | limit := t.Add(maxLookahead) |
| 245 | for cur.Before(limit) { |
| 246 | if !bitSet(s.month, int(cur.Month())) { |
| 247 | cur = s.rebuild(time.Date(cur.Year(), cur.Month()+1, 1, 0, 0, 0, 0, loc), loc) |
| 248 | continue |
| 249 | } |
| 250 | if !s.dayMatches(cur) { |
| 251 | cur = s.rebuild(time.Date(cur.Year(), cur.Month(), cur.Day()+1, 0, 0, 0, 0, loc), loc) |
| 252 | continue |
| 253 | } |
| 254 | if !bitSet(s.hour, cur.Hour()) { |
| 255 | cur = s.rebuild(time.Date(cur.Year(), cur.Month(), cur.Day(), cur.Hour()+1, 0, 0, 0, loc), loc) |
| 256 | continue |
| 257 | } |
| 258 | if !bitSet(s.min, cur.Minute()) { |
| 259 | cur = s.rebuild(time.Date(cur.Year(), cur.Month(), cur.Day(), cur.Hour(), cur.Minute()+1, 0, 0, loc), loc) |
| 260 | continue |
| 261 | } |
| 262 | if s.hasSec && !bitSet(s.sec, cur.Second()) { |
| 263 | cur = time.Date(cur.Year(), cur.Month(), cur.Day(), cur.Hour(), cur.Minute(), cur.Second()+1, 0, loc) |
| 264 | continue |
| 265 | } |
| 266 | return cur |
| 267 | } |
| 268 | return time.Time{} |
| 269 | } |
| 270 | |
| 271 | // rebuild reconstructs t through time.Date in loc, dropping sub-second |
| 272 | // precision below the schedule's resolution so increments stay aligned. |
| 273 | func (s cronSchedule) rebuild(t time.Time, loc *time.Location) time.Time { |
| 274 | if s.hasSec { |
| 275 | return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), 0, loc) |
| 276 | } |
| 277 | return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), 0, 0, loc) |
| 278 | } |
| 279 | |
| 280 | // dayMatches applies Vixie or-semantics: when both day-of-month and |
| 281 | // day-of-week are restricted (neither is the full range), a day matches |
| 282 | // if it satisfies either; when one is unrestricted, only the other |
| 283 | // constrains. |
| 284 | func (s cronSchedule) dayMatches(t time.Time) bool { |
| 285 | domRestricted := s.dom != fullMask(1, 31) |
| 286 | dowRestricted := s.dow != fullMask(0, 6) |
| 287 | domHit := bitSet(s.dom, t.Day()) |
| 288 | dowHit := bitSet(s.dow, int(t.Weekday())) |
| 289 | |
| 290 | switch { |
| 291 | case domRestricted && dowRestricted: |
| 292 | return domHit || dowHit |
| 293 | case domRestricted: |
| 294 | return domHit |
| 295 | case dowRestricted: |
| 296 | return dowHit |
| 297 | default: |
| 298 | return true |
| 299 | } |
| 300 | } |
| 301 | |
| 302 | // bitSet reports whether bit v is set in mask. |
| 303 | func bitSet(mask uint64, v int) bool { |
| 304 | return mask&(1<<uint(v)) != 0 |
| 305 | } |
| 306 | |
| 307 | // fullMask returns the bitset with every bit in [lo, hi] set, matching |
| 308 | // the parser's representation of a "*" field. |
| 309 | func fullMask(lo, hi int) uint64 { |
| 310 | var m uint64 |
| 311 | for v := lo; v <= hi; v++ { |
| 312 | m |= 1 << uint(v) |
| 313 | } |
| 314 | return m |
| 315 | } |
| 316 | |