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 }