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  }