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