git.sr.ht/~pingoo/stdx@v0.0.0-20240218134121-094174641f6e/cron/parser.go (about) 1 package cron 2 3 import ( 4 "fmt" 5 "math" 6 "strconv" 7 "strings" 8 "time" 9 ) 10 11 // Configuration options for creating a parser. Most options specify which 12 // fields should be included, while others enable features. If a field is not 13 // included the parser will assume a default value. These options do not change 14 // the order fields are parse in. 15 type ParseOption int 16 17 const ( 18 Second ParseOption = 1 << iota // Seconds field, default 0 19 SecondOptional // Optional seconds field, default 0 20 Minute // Minutes field, default 0 21 Hour // Hours field, default 0 22 Dom // Day of month field, default * 23 Month // Month field, default * 24 Dow // Day of week field, default * 25 DowOptional // Optional day of week field, default * 26 Descriptor // Allow descriptors such as @monthly, @weekly, etc. 27 ) 28 29 var places = []ParseOption{ 30 Second, 31 Minute, 32 Hour, 33 Dom, 34 Month, 35 Dow, 36 } 37 38 var defaults = []string{ 39 "0", 40 "0", 41 "0", 42 "*", 43 "*", 44 "*", 45 } 46 47 // A custom Parser that can be configured. 48 type Parser struct { 49 options ParseOption 50 } 51 52 // NewParser creates a Parser with custom options. 53 // 54 // It panics if more than one Optional is given, since it would be impossible to 55 // correctly infer which optional is provided or missing in general. 56 // 57 // Examples 58 // 59 // // Standard parser without descriptors 60 // specParser := NewParser(Minute | Hour | Dom | Month | Dow) 61 // sched, err := specParser.Parse("0 0 15 */3 *") 62 // 63 // // Same as above, just excludes time fields 64 // specParser := NewParser(Dom | Month | Dow) 65 // sched, err := specParser.Parse("15 */3 *") 66 // 67 // // Same as above, just makes Dow optional 68 // specParser := NewParser(Dom | Month | DowOptional) 69 // sched, err := specParser.Parse("15 */3") 70 func NewParser(options ParseOption) Parser { 71 optionals := 0 72 if options&DowOptional > 0 { 73 optionals++ 74 } 75 if options&SecondOptional > 0 { 76 optionals++ 77 } 78 if optionals > 1 { 79 panic("multiple optionals may not be configured") 80 } 81 return Parser{options} 82 } 83 84 // Parse returns a new crontab schedule representing the given spec. 85 // It returns a descriptive error if the spec is not valid. 86 // It accepts crontab specs and features configured by NewParser. 87 func (p Parser) Parse(spec string) (Schedule, error) { 88 if len(spec) == 0 { 89 return nil, fmt.Errorf("empty spec string") 90 } 91 92 // Extract timezone if present 93 var loc = time.Local 94 if strings.HasPrefix(spec, "TZ=") || strings.HasPrefix(spec, "CRON_TZ=") { 95 var err error 96 i := strings.Index(spec, " ") 97 eq := strings.Index(spec, "=") 98 if loc, err = time.LoadLocation(spec[eq+1 : i]); err != nil { 99 return nil, fmt.Errorf("provided bad location %s: %v", spec[eq+1:i], err) 100 } 101 spec = strings.TrimSpace(spec[i:]) 102 } 103 104 // Handle named schedules (descriptors), if configured 105 if strings.HasPrefix(spec, "@") { 106 if p.options&Descriptor == 0 { 107 return nil, fmt.Errorf("parser does not accept descriptors: %v", spec) 108 } 109 return parseDescriptor(spec, loc) 110 } 111 112 // Split on whitespace. 113 fields := strings.Fields(spec) 114 115 // Validate & fill in any omitted or optional fields 116 var err error 117 fields, err = normalizeFields(fields, p.options) 118 if err != nil { 119 return nil, err 120 } 121 122 field := func(field string, r bounds) uint64 { 123 if err != nil { 124 return 0 125 } 126 var bits uint64 127 bits, err = getField(field, r) 128 return bits 129 } 130 131 var ( 132 second = field(fields[0], seconds) 133 minute = field(fields[1], minutes) 134 hour = field(fields[2], hours) 135 dayofmonth = field(fields[3], dom) 136 month = field(fields[4], months) 137 dayofweek = field(fields[5], dow) 138 ) 139 if err != nil { 140 return nil, err 141 } 142 143 return &SpecSchedule{ 144 Second: second, 145 Minute: minute, 146 Hour: hour, 147 Dom: dayofmonth, 148 Month: month, 149 Dow: dayofweek, 150 Location: loc, 151 }, nil 152 } 153 154 // normalizeFields takes a subset set of the time fields and returns the full set 155 // with defaults (zeroes) populated for unset fields. 156 // 157 // As part of performing this function, it also validates that the provided 158 // fields are compatible with the configured options. 159 func normalizeFields(fields []string, options ParseOption) ([]string, error) { 160 // Validate optionals & add their field to options 161 optionals := 0 162 if options&SecondOptional > 0 { 163 options |= Second 164 optionals++ 165 } 166 if options&DowOptional > 0 { 167 options |= Dow 168 optionals++ 169 } 170 if optionals > 1 { 171 return nil, fmt.Errorf("multiple optionals may not be configured") 172 } 173 174 // Figure out how many fields we need 175 max := 0 176 for _, place := range places { 177 if options&place > 0 { 178 max++ 179 } 180 } 181 min := max - optionals 182 183 // Validate number of fields 184 if count := len(fields); count < min || count > max { 185 if min == max { 186 return nil, fmt.Errorf("expected exactly %d fields, found %d: %s", min, count, fields) 187 } 188 return nil, fmt.Errorf("expected %d to %d fields, found %d: %s", min, max, count, fields) 189 } 190 191 // Populate the optional field if not provided 192 if min < max && len(fields) == min { 193 switch { 194 case options&DowOptional > 0: 195 fields = append(fields, defaults[5]) // TODO: improve access to default 196 case options&SecondOptional > 0: 197 fields = append([]string{defaults[0]}, fields...) 198 default: 199 return nil, fmt.Errorf("unknown optional field") 200 } 201 } 202 203 // Populate all fields not part of options with their defaults 204 n := 0 205 expandedFields := make([]string, len(places)) 206 copy(expandedFields, defaults) 207 for i, place := range places { 208 if options&place > 0 { 209 expandedFields[i] = fields[n] 210 n++ 211 } 212 } 213 return expandedFields, nil 214 } 215 216 var standardParser = NewParser( 217 Minute | Hour | Dom | Month | Dow | Descriptor, 218 ) 219 220 // ParseStandard returns a new crontab schedule representing the given 221 // standardSpec (https://en.wikipedia.org/wiki/Cron). It requires 5 entries 222 // representing: minute, hour, day of month, month and day of week, in that 223 // order. It returns a descriptive error if the spec is not valid. 224 // 225 // It accepts 226 // - Standard crontab specs, e.g. "* * * * ?" 227 // - Descriptors, e.g. "@midnight", "@every 1h30m" 228 func ParseStandard(standardSpec string) (Schedule, error) { 229 return standardParser.Parse(standardSpec) 230 } 231 232 // getField returns an Int with the bits set representing all of the times that 233 // the field represents or error parsing field value. A "field" is a comma-separated 234 // list of "ranges". 235 func getField(field string, r bounds) (uint64, error) { 236 var bits uint64 237 ranges := strings.FieldsFunc(field, func(r rune) bool { return r == ',' }) 238 for _, expr := range ranges { 239 bit, err := getRange(expr, r) 240 if err != nil { 241 return bits, err 242 } 243 bits |= bit 244 } 245 return bits, nil 246 } 247 248 // getRange returns the bits indicated by the given expression: 249 // 250 // number | number "-" number [ "/" number ] 251 // 252 // or error parsing range. 253 func getRange(expr string, r bounds) (uint64, error) { 254 var ( 255 start, end, step uint 256 rangeAndStep = strings.Split(expr, "/") 257 lowAndHigh = strings.Split(rangeAndStep[0], "-") 258 singleDigit = len(lowAndHigh) == 1 259 err error 260 ) 261 262 var extra uint64 263 if lowAndHigh[0] == "*" || lowAndHigh[0] == "?" { 264 start = r.min 265 end = r.max 266 extra = starBit 267 } else { 268 start, err = parseIntOrName(lowAndHigh[0], r.names) 269 if err != nil { 270 return 0, err 271 } 272 switch len(lowAndHigh) { 273 case 1: 274 end = start 275 case 2: 276 end, err = parseIntOrName(lowAndHigh[1], r.names) 277 if err != nil { 278 return 0, err 279 } 280 default: 281 return 0, fmt.Errorf("too many hyphens: %s", expr) 282 } 283 } 284 285 switch len(rangeAndStep) { 286 case 1: 287 step = 1 288 case 2: 289 step, err = mustParseInt(rangeAndStep[1]) 290 if err != nil { 291 return 0, err 292 } 293 294 // Special handling: "N/step" means "N-max/step". 295 if singleDigit { 296 end = r.max 297 } 298 if step > 1 { 299 extra = 0 300 } 301 default: 302 return 0, fmt.Errorf("too many slashes: %s", expr) 303 } 304 305 if start < r.min { 306 return 0, fmt.Errorf("beginning of range (%d) below minimum (%d): %s", start, r.min, expr) 307 } 308 if end > r.max { 309 return 0, fmt.Errorf("end of range (%d) above maximum (%d): %s", end, r.max, expr) 310 } 311 if start > end { 312 return 0, fmt.Errorf("beginning of range (%d) beyond end of range (%d): %s", start, end, expr) 313 } 314 if step == 0 { 315 return 0, fmt.Errorf("step of range should be a positive number: %s", expr) 316 } 317 318 return getBits(start, end, step) | extra, nil 319 } 320 321 // parseIntOrName returns the (possibly-named) integer contained in expr. 322 func parseIntOrName(expr string, names map[string]uint) (uint, error) { 323 if names != nil { 324 if namedInt, ok := names[strings.ToLower(expr)]; ok { 325 return namedInt, nil 326 } 327 } 328 return mustParseInt(expr) 329 } 330 331 // mustParseInt parses the given expression as an int or returns an error. 332 func mustParseInt(expr string) (uint, error) { 333 num, err := strconv.Atoi(expr) 334 if err != nil { 335 return 0, fmt.Errorf("failed to parse int from %s: %s", expr, err) 336 } 337 if num < 0 { 338 return 0, fmt.Errorf("negative number (%d) not allowed: %s", num, expr) 339 } 340 341 return uint(num), nil 342 } 343 344 // getBits sets all bits in the range [min, max], modulo the given step size. 345 func getBits(min, max, step uint) uint64 { 346 var bits uint64 347 348 // If step is 1, use shifts. 349 if step == 1 { 350 return ^(math.MaxUint64 << (max + 1)) & (math.MaxUint64 << min) 351 } 352 353 // Else, use a simple loop. 354 for i := min; i <= max; i += step { 355 bits |= 1 << i 356 } 357 return bits 358 } 359 360 // all returns all bits within the given bounds. (plus the star bit) 361 func all(r bounds) uint64 { 362 return getBits(r.min, r.max, 1) | starBit 363 } 364 365 // parseDescriptor returns a predefined schedule for the expression, or error if none matches. 366 func parseDescriptor(descriptor string, loc *time.Location) (Schedule, error) { 367 switch descriptor { 368 case "@yearly", "@annually": 369 return &SpecSchedule{ 370 Second: 1 << seconds.min, 371 Minute: 1 << minutes.min, 372 Hour: 1 << hours.min, 373 Dom: 1 << dom.min, 374 Month: 1 << months.min, 375 Dow: all(dow), 376 Location: loc, 377 }, nil 378 379 case "@monthly": 380 return &SpecSchedule{ 381 Second: 1 << seconds.min, 382 Minute: 1 << minutes.min, 383 Hour: 1 << hours.min, 384 Dom: 1 << dom.min, 385 Month: all(months), 386 Dow: all(dow), 387 Location: loc, 388 }, nil 389 390 case "@weekly": 391 return &SpecSchedule{ 392 Second: 1 << seconds.min, 393 Minute: 1 << minutes.min, 394 Hour: 1 << hours.min, 395 Dom: all(dom), 396 Month: all(months), 397 Dow: 1 << dow.min, 398 Location: loc, 399 }, nil 400 401 case "@daily", "@midnight": 402 return &SpecSchedule{ 403 Second: 1 << seconds.min, 404 Minute: 1 << minutes.min, 405 Hour: 1 << hours.min, 406 Dom: all(dom), 407 Month: all(months), 408 Dow: all(dow), 409 Location: loc, 410 }, nil 411 412 case "@hourly": 413 return &SpecSchedule{ 414 Second: 1 << seconds.min, 415 Minute: 1 << minutes.min, 416 Hour: all(hours), 417 Dom: all(dom), 418 Month: all(months), 419 Dow: all(dow), 420 Location: loc, 421 }, nil 422 423 } 424 425 const every = "@every " 426 if strings.HasPrefix(descriptor, every) { 427 duration, err := time.ParseDuration(descriptor[len(every):]) 428 if err != nil { 429 return nil, fmt.Errorf("failed to parse duration %s: %s", descriptor, err) 430 } 431 return Every(duration), nil 432 } 433 434 return nil, fmt.Errorf("unrecognized descriptor: %s", descriptor) 435 }