import automata/cron/ast.{
type Field, type RawCron, DayOfMonth, DayOfWeek, Hour, Minute, Month,
}
import automata/internal/calendar
import gleam/int
import gleam/list
import gleam/string
pub type Selector {
Any
Values(List(Item))
}
pub type Item {
Exact(Int)
Range(Int, Int)
Step(StepBase, Int)
}
pub type StepBase {
StepAny
StepExact(Int)
StepRange(Int, Int)
}
/// A cron expression that has passed `validate/1`. `opaque` so the
/// only way to obtain a value is through validation, preventing
/// callers from forging "validated" inputs.
pub opaque type ValidCron {
ValidCron(
minute: Selector,
hour: Selector,
day_of_month: Selector,
month: Selector,
day_of_week: Selector,
)
}
pub fn minute(spec: ValidCron) -> Selector {
spec.minute
}
pub fn hour(spec: ValidCron) -> Selector {
spec.hour
}
pub fn day_of_month(spec: ValidCron) -> Selector {
spec.day_of_month
}
pub fn month(spec: ValidCron) -> Selector {
spec.month
}
pub fn day_of_week(spec: ValidCron) -> Selector {
spec.day_of_week
}
pub type ValidationError {
UnsupportedSyntax(field: Field, value: String)
InvalidNumber(field: Field, value: String)
InvalidAlias(field: Field, value: String)
InvalidRange(field: Field, value: String)
InvalidStep(field: Field, value: String)
InvalidList(field: Field, value: String)
OutOfRange(field: Field, min: Int, max: Int, actual: Int)
ImpossibleDate(day_of_month: String, month: String)
}
type AliasMode {
NoAliases
MonthAliases
DayAliases
}
pub fn validate(raw raw: RawCron) -> Result(ValidCron, ValidationError) {
case parse_selector(Minute, raw.minute, 0, 59, NoAliases) {
Error(error) -> Error(error)
Ok(minute) ->
case parse_selector(Hour, raw.hour, 0, 23, NoAliases) {
Error(error) -> Error(error)
Ok(hour) ->
case parse_selector(DayOfMonth, raw.day_of_month, 1, 31, NoAliases) {
Error(error) -> Error(error)
Ok(day_of_month) ->
case parse_selector(Month, raw.month, 1, 12, MonthAliases) {
Error(error) -> Error(error)
Ok(month) ->
case
parse_selector(DayOfWeek, raw.day_of_week, 0, 7, DayAliases)
{
Error(error) -> Error(error)
Ok(day_of_week) -> {
let spec =
ValidCron(
minute:,
hour:,
day_of_month:,
month:,
day_of_week:,
)
case validate_semantics(spec) {
Ok(_) -> Ok(spec)
Error(error) -> Error(error)
}
}
}
}
}
}
}
}
pub fn to_string(spec: ValidCron) -> String {
string.join(
[
selector_to_string(spec.minute),
selector_to_string(spec.hour),
selector_to_string(spec.day_of_month),
selector_to_string(spec.month),
selector_to_string(spec.day_of_week),
],
with: " ",
)
}
fn validate_semantics(spec: ValidCron) -> Result(Nil, ValidationError) {
case day_of_week_is_any(spec.day_of_week) {
False -> Ok(Nil)
True ->
case schedule_possible(spec.day_of_month, spec.month) {
True -> Ok(Nil)
False ->
Error(ImpossibleDate(
day_of_month: selector_to_string(spec.day_of_month),
month: selector_to_string(spec.month),
))
}
}
}
fn day_of_week_is_any(selector: Selector) -> Bool {
case selector {
Any -> True
Values(_) -> False
}
}
fn schedule_possible(day_of_month: Selector, month: Selector) -> Bool {
let months = selector_values(month, 1, 12, normalize_day_of_week: False)
let days = selector_values(day_of_month, 1, 31, normalize_day_of_week: False)
list.any(months, fn(month) {
let maximum = calendar.days_in_month(2024, month)
list.any(days, fn(day) { day <= maximum })
|| list.any(days, fn(day) { calendar.days_in_month(2025, month) >= day })
})
}
fn parse_selector(
field: Field,
value: String,
min: Int,
max: Int,
aliases: AliasMode,
) -> Result(Selector, ValidationError) {
case value {
"*" -> Ok(Any)
_ ->
case list.any(string.split(value, on: ","), string.is_empty) {
True -> Error(InvalidList(field: field, value: value))
False ->
parse_items(
string.split(value, on: ","),
field,
min,
max,
aliases,
[],
)
}
}
}
fn parse_items(
parts: List(String),
field: Field,
min: Int,
max: Int,
aliases: AliasMode,
acc: List(Item),
) -> Result(Selector, ValidationError) {
case parts {
[] -> Ok(Values(list.reverse(acc)))
[part, ..rest] ->
case parse_item(field, part, min, max, aliases) {
Ok(item) -> parse_items(rest, field, min, max, aliases, [item, ..acc])
Error(error) -> Error(error)
}
}
}
fn parse_item(
field: Field,
part: String,
min: Int,
max: Int,
aliases: AliasMode,
) -> Result(Item, ValidationError) {
case string.contains(part, contain: "/") {
True -> parse_step(field, part, min, max, aliases)
False ->
case string.contains(part, contain: "-") {
True -> parse_range(field, part, min, max, aliases)
False ->
case parse_value(field, part, min, max, aliases) {
Ok(value) -> Ok(Exact(value))
Error(error) -> Error(error)
}
}
}
}
fn parse_step(
field: Field,
part: String,
min: Int,
max: Int,
aliases: AliasMode,
) -> Result(Item, ValidationError) {
case string.split(part, on: "/") {
[base, step_text] ->
case base == "" || step_text == "" {
True -> Error(InvalidStep(field: field, value: part))
False ->
case int.parse(step_text) {
Error(_) -> Error(InvalidStep(field: field, value: part))
Ok(step) ->
case step > 0 {
False -> Error(InvalidStep(field: field, value: part))
True ->
case parse_step_base(field, base, min, max, aliases) {
Ok(step_base) -> Ok(Step(step_base, step))
Error(error) -> Error(error)
}
}
}
}
_ -> Error(InvalidStep(field: field, value: part))
}
}
fn parse_step_base(
field: Field,
base: String,
min: Int,
max: Int,
aliases: AliasMode,
) -> Result(StepBase, ValidationError) {
case base {
"*" -> Ok(StepAny)
_ ->
case string.contains(base, contain: "-") {
True ->
case string.split(base, on: "-") {
[start_text, end_text] ->
case start_text == "" || end_text == "" {
True -> Error(InvalidRange(field: field, value: base))
False ->
case parse_value(field, start_text, min, max, aliases) {
Error(error) -> Error(error)
Ok(start) ->
case parse_value(field, end_text, min, max, aliases) {
Error(error) -> Error(error)
Ok(end) ->
case start <= end {
True -> Ok(StepRange(start, end))
False ->
Error(InvalidRange(field: field, value: base))
}
}
}
}
_ -> Error(InvalidRange(field: field, value: base))
}
False ->
case parse_value(field, base, min, max, aliases) {
Ok(value) -> Ok(StepExact(value))
Error(error) -> Error(error)
}
}
}
}
fn parse_range(
field: Field,
part: String,
min: Int,
max: Int,
aliases: AliasMode,
) -> Result(Item, ValidationError) {
case string.split(part, on: "-") {
[start_text, end_text] ->
case start_text == "" || end_text == "" {
True -> Error(InvalidRange(field: field, value: part))
False ->
case parse_value(field, start_text, min, max, aliases) {
Error(error) -> Error(error)
Ok(start) ->
case parse_value(field, end_text, min, max, aliases) {
Error(error) -> Error(error)
Ok(end) ->
case start <= end {
True -> Ok(Range(start, end))
False -> Error(InvalidRange(field: field, value: part))
}
}
}
}
_ -> Error(InvalidRange(field: field, value: part))
}
}
fn parse_value(
field: Field,
value: String,
min: Int,
max: Int,
aliases: AliasMode,
) -> Result(Int, ValidationError) {
case int.parse(value) {
Ok(number) -> ensure_range(field, number, min, max)
Error(_) ->
case parse_alias(field, value, aliases) {
Ok(number) -> ensure_range(field, number, min, max)
Error(Nil) ->
case has_reserved_quartz_syntax(value) {
True -> Error(UnsupportedSyntax(field: field, value: value))
False ->
case aliases {
NoAliases -> Error(InvalidNumber(field: field, value: value))
_ -> Error(InvalidAlias(field: field, value: value))
}
}
}
}
}
/// Detect reserved Quartz-style extensions (`?`, `L`, `W`, `H`, `#`) that
/// the validator does not support. Run only after `int.parse` and
/// `parse_alias` have both failed, so that perfectly valid alias tokens
/// like `WED`, `JUL`, `THU` (which contain `W`, `L`, `H` as substrings)
/// still reach the alias resolver before being misclassified.
fn has_reserved_quartz_syntax(value: String) -> Bool {
string.contains(value, contain: "?")
|| string.contains(value, contain: "L")
|| string.contains(value, contain: "W")
|| string.contains(value, contain: "#")
|| string.contains(value, contain: "H")
}
fn ensure_range(
field: Field,
number: Int,
min: Int,
max: Int,
) -> Result(Int, ValidationError) {
case number < min || number > max {
True -> Error(OutOfRange(field: field, min: min, max: max, actual: number))
False -> Ok(number)
}
}
fn parse_alias(
_field: Field,
value: String,
aliases: AliasMode,
) -> Result(Int, Nil) {
let upper = string.uppercase(value)
case aliases {
NoAliases -> Error(Nil)
MonthAliases ->
case upper {
"JAN" -> Ok(1)
"FEB" -> Ok(2)
"MAR" -> Ok(3)
"APR" -> Ok(4)
"MAY" -> Ok(5)
"JUN" -> Ok(6)
"JUL" -> Ok(7)
"AUG" -> Ok(8)
"SEP" -> Ok(9)
"OCT" -> Ok(10)
"NOV" -> Ok(11)
"DEC" -> Ok(12)
_ -> Error(Nil)
}
DayAliases ->
case upper {
"SUN" -> Ok(0)
"MON" -> Ok(1)
"TUE" -> Ok(2)
"WED" -> Ok(3)
"THU" -> Ok(4)
"FRI" -> Ok(5)
"SAT" -> Ok(6)
_ -> Error(Nil)
}
}
}
pub fn selector_values(
selector: Selector,
min: Int,
max: Int,
normalize_day_of_week normalize_day_of_week: Bool,
) -> List(Int) {
let values = case selector {
Any -> inclusive_range(min, max)
Values(items) ->
items
|> list.flat_map(fn(item) { item_values(item, min, max) })
}
case normalize_day_of_week {
True ->
values
|> list.map(fn(value) {
case value == 7 {
True -> 0
False -> value
}
})
|> dedup_sort([])
False -> dedup_sort(values, [])
}
}
fn item_values(item: Item, min: Int, max: Int) -> List(Int) {
case item {
Exact(value) -> [value]
Range(start, end) -> inclusive_range(start, end)
Step(base, step) ->
case base {
StepAny -> stepped_values(min, max, step)
StepExact(start) -> stepped_values(start, max, step)
StepRange(start, end) -> stepped_values(start, end, step)
}
}
}
fn stepped_values(start: Int, stop: Int, step: Int) -> List(Int) {
case start > stop {
True -> []
False -> [start, ..stepped_values(start + step, stop, step)]
}
}
fn inclusive_range(start: Int, stop: Int) -> List(Int) {
case start > stop {
True -> []
False -> [start, ..inclusive_range(start + 1, stop)]
}
}
fn dedup_sort(values: List(Int), acc: List(Int)) -> List(Int) {
case list.sort(values, by: int.compare) {
[] -> list.reverse(acc)
[first, ..rest] -> dedup_sorted(rest, first, [first, ..acc])
}
}
fn dedup_sorted(rest: List(Int), previous: Int, acc: List(Int)) -> List(Int) {
case rest {
[] -> list.reverse(acc)
[value, ..tail] ->
case value == previous {
True -> dedup_sorted(tail, previous, acc)
False -> dedup_sorted(tail, value, [value, ..acc])
}
}
}
fn selector_to_string(selector: Selector) -> String {
case selector {
Any -> "*"
Values(items) -> string.join(list.map(items, item_to_string), with: ",")
}
}
fn item_to_string(item: Item) -> String {
case item {
Exact(value) -> int.to_string(value)
Range(start, end) -> int.to_string(start) <> "-" <> int.to_string(end)
Step(base, step) -> step_base_to_string(base) <> "/" <> int.to_string(step)
}
}
fn step_base_to_string(base: StepBase) -> String {
case base {
StepAny -> "*"
StepExact(value) -> int.to_string(value)
StepRange(start, end) -> int.to_string(start) <> "-" <> int.to_string(end)
}
}