github.com/TBD54566975/ftl@v0.219.0/internal/cron/pattern.go (about)

     1  package cron
     2  
     3  import (
     4  	"fmt"
     5  	"strconv"
     6  	"strings"
     7  	"time"
     8  
     9  	"github.com/alecthomas/participle/v2"
    10  	"github.com/alecthomas/participle/v2/lexer"
    11  
    12  	"github.com/TBD54566975/ftl/internal/slices"
    13  )
    14  
    15  var (
    16  	lex = lexer.MustSimple([]lexer.SimpleRule{
    17  		{Name: "Whitespace", Pattern: `\s+`},
    18  		{Name: "Ident", Pattern: `\b[a-zA-Z_][a-zA-Z0-9_]*\b`},
    19  		{Name: "Comment", Pattern: `//.*`},
    20  		{Name: "String", Pattern: `"(?:\\.|[^"])*"`},
    21  		{Name: "Number", Pattern: `[0-9]+(?:\.[0-9]+)?`},
    22  		{Name: "Punct", Pattern: `[%/\-\_:[\]{}<>()*+?.,\\^$|#~!\'@]`},
    23  	})
    24  
    25  	parserOptions = []participle.Option{
    26  		participle.Lexer(lex),
    27  		participle.Elide("Whitespace"),
    28  		participle.Unquote(),
    29  		participle.Map(func(token lexer.Token) (lexer.Token, error) {
    30  			token.Value = strings.TrimSpace(strings.TrimPrefix(token.Value, "//"))
    31  			return token, nil
    32  		}, "Comment"),
    33  	}
    34  
    35  	parser = participle.MustBuild[Pattern](parserOptions...)
    36  )
    37  
    38  type Pattern struct {
    39  	Components []Component `parser:"@@*"`
    40  }
    41  
    42  func (p Pattern) String() string {
    43  	return strings.Join(slices.Map(p.Components, func(component Component) string {
    44  		return component.String()
    45  	}), " ")
    46  }
    47  
    48  func (p Pattern) standardizedComponents() ([]Component, error) {
    49  	switch len(p.Components) {
    50  	case 5:
    51  		// Convert "a b c d e" -> "0 a b c d e *"
    52  		components := make([]Component, 7)
    53  		components[0] = newComponentWithValue(0)
    54  		copy(components[1:], p.Components)
    55  		components[6] = newComponentWithFullRange()
    56  		return components, nil
    57  	case 6:
    58  		// Might be two different formats unfortunately.
    59  		// Could be:
    60  		// - seconds, minutes, hours, day of month, month, day of week
    61  		// - minutes, hours, day of month, month, day of week, year
    62  		// Detect by looking for 4 digit numbers in the last component, and then treat it as a year column
    63  		if isComponentLikelyToBeYearComponent(p.Components[5]) {
    64  			// Convert "a b c d e f" -> "0 a b c d e f"
    65  			components := make([]Component, 7)
    66  			components[0] = newComponentWithValue(0)
    67  			copy(components[1:], p.Components)
    68  			return components, nil
    69  		} else {
    70  			// Convert "a b c d e f" -> "a b c d e f *"
    71  			components := make([]Component, 7)
    72  			copy(components[0:], p.Components)
    73  			components[6] = newComponentWithFullRange()
    74  			return components, nil
    75  		}
    76  	case 7:
    77  		return p.Components, nil
    78  	default:
    79  		return nil, fmt.Errorf("expected 5-7 components, got %d", len(p.Components))
    80  	}
    81  }
    82  
    83  func isComponentLikelyToBeYearComponent(component Component) bool {
    84  	for _, s := range component.List {
    85  		if s.ValueRange.Start != nil && *s.ValueRange.Start >= 1000 {
    86  			return true
    87  		}
    88  		if s.ValueRange.End != nil && *s.ValueRange.End >= 1000 {
    89  			return true
    90  		}
    91  	}
    92  	return false
    93  }
    94  
    95  type Component struct {
    96  	List []Step `parser:"(@@ (',' @@)*)"`
    97  }
    98  
    99  func newComponentWithFullRange() Component {
   100  	return Component{
   101  		List: []Step{
   102  			{
   103  				ValueRange: ValueRange{IsFullRange: true},
   104  			},
   105  		},
   106  	}
   107  }
   108  
   109  func newComponentWithValue(value int) Component {
   110  	return Component{
   111  		List: []Step{
   112  			newStepWithValue(value),
   113  		},
   114  	}
   115  }
   116  
   117  func (c Component) String() string {
   118  	return strings.Join(slices.Map(c.List, func(step Step) string {
   119  		return step.String()
   120  	}), ",")
   121  }
   122  
   123  type Step struct {
   124  	ValueRange ValueRange `parser:"@@"`
   125  	Step       *int       `parser:"('/' @Number)?"`
   126  }
   127  
   128  func newStepWithValue(value int) Step {
   129  	return Step{
   130  		ValueRange: ValueRange{Start: &value, End: nil},
   131  	}
   132  }
   133  
   134  func (s *Step) String() string {
   135  	if s.Step != nil {
   136  		return fmt.Sprintf("%s/%d", s.ValueRange.String(), *s.Step)
   137  	}
   138  	return s.ValueRange.String()
   139  }
   140  
   141  type ValueRange struct {
   142  	IsFullRange bool `parser:"(@'*'"`
   143  	Start       *int `parser:"| @Number"`
   144  	End         *int `parser:"('-' @Number)?)"`
   145  }
   146  
   147  func (r *ValueRange) String() string {
   148  	if r.IsFullRange {
   149  		return "*"
   150  	}
   151  	if r.End != nil {
   152  		return fmt.Sprintf("%d-%d", *r.Start, *r.End)
   153  	}
   154  	return strconv.Itoa(*r.Start)
   155  }
   156  
   157  func Parse(text string) (Pattern, error) {
   158  	pattern, err := parser.ParseString("", text)
   159  	if err != nil {
   160  		return Pattern{}, err
   161  	}
   162  	// Validate to make sure that a pattern has no mistakes in the cron format, and that there is a valid next value from a set point in time
   163  	_, err = NextAfter(*pattern, time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC), true)
   164  	if err != nil {
   165  		return Pattern{}, err
   166  	}
   167  	return *pattern, nil
   168  }