Packages

Background job processing for the Nova ecosystem

Current section

Files

Jump to
shigoto src shigoto_cron_parser.erl
Raw

src/shigoto_cron_parser.erl

-module(shigoto_cron_parser).
-moduledoc """
Parses standard 5-field cron expressions and checks if a given datetime
matches. Fields: minute, hour, day-of-month, month, day-of-week.
Supported syntax per field:
- `*` — any value
- `N` — exact value
- `N-M` — inclusive range
- `*/N` — step from start
- `N-M/S` — step within range
- `N,M,O` — comma-separated list (each element may be a value, range, or step)
Aliases: `@yearly`, `@annually`, `@monthly`, `@weekly`, `@daily`, `@midnight`, `@hourly`.
""".
-export([parse/1, matches/2]).
-type field() :: star | {step, pos_integer()} | {values, [non_neg_integer()]}.
-type cron_expr() :: #{
minute := field(),
hour := field(),
dom := field(),
month := field(),
dow := field()
}.
-export_type([cron_expr/0, field/0]).
-doc "Parse a 5-field cron expression or alias into a structured map.".
-spec parse(binary()) -> {ok, cron_expr()} | {error, term()}.
parse(~"@yearly") ->
parse(~"0 0 1 1 *");
parse(~"@annually") ->
parse(~"0 0 1 1 *");
parse(~"@monthly") ->
parse(~"0 0 1 * *");
parse(~"@weekly") ->
parse(~"0 0 * * 0");
parse(~"@daily") ->
parse(~"0 0 * * *");
parse(~"@midnight") ->
parse(~"0 0 * * *");
parse(~"@hourly") ->
parse(~"0 * * * *");
parse(Bin) when is_binary(Bin) ->
Fields = binary:split(Bin, ~" ", [global, trim_all]),
case Fields of
[Min, Hour, Dom, Mon, Dow] ->
try
{ok, #{
minute => parse_field(Min, 0, 59),
hour => parse_field(Hour, 0, 23),
dom => parse_field(Dom, 1, 31),
month => parse_field(Mon, 1, 12),
dow => parse_field(Dow, 0, 6)
}}
catch
throw:{parse_error, Reason} -> {error, Reason}
end;
_ ->
{error, {bad_field_count, length(Fields)}}
end.
-doc "Check if a `calendar:datetime()` matches a parsed cron expression.".
-spec matches(cron_expr(), calendar:datetime()) -> boolean().
matches(Expr, {{Year, Month, Day}, {Hour, Minute, _Second}}) ->
Dow = day_of_week(Year, Month, Day),
field_matches(maps:get(minute, Expr), Minute) andalso
field_matches(maps:get(hour, Expr), Hour) andalso
field_matches(maps:get(dom, Expr), Day) andalso
field_matches(maps:get(month, Expr), Month) andalso
field_matches(maps:get(dow, Expr), Dow).
%%----------------------------------------------------------------------
%% Internal
%%----------------------------------------------------------------------
-spec parse_field(binary(), non_neg_integer(), non_neg_integer()) -> field().
parse_field(~"*", _Min, _Max) ->
star;
parse_field(<<"*/", Rest/binary>>, Min, Max) ->
Step = to_int(Rest),
validate_step(Step),
expand_step(Min, Max, Step);
parse_field(Bin, Min, Max) ->
Parts = binary:split(Bin, ~",", [global]),
Values = lists:usort(
lists:flatmap(fun(P) -> parse_part(P, Min, Max) end, Parts)
),
{values, Values}.
-spec parse_part(binary(), non_neg_integer(), non_neg_integer()) -> [non_neg_integer()].
parse_part(Bin, Min, Max) ->
case binary:split(Bin, ~"/") of
[Range, StepBin] ->
Step = to_int(StepBin),
validate_step(Step),
{Lo, Hi} = parse_range(Range, Min, Max),
expand_step_range(Lo, Hi, Step);
[_] ->
case binary:split(Bin, ~"-") of
[LoBin, HiBin] ->
Lo = clamp(to_int(LoBin), Min, Max),
Hi = clamp(to_int(HiBin), Min, Max),
lists:seq(Lo, Hi);
[ValBin] ->
[clamp(to_int(ValBin), Min, Max)]
end
end.
-spec parse_range(binary(), non_neg_integer(), non_neg_integer()) ->
{non_neg_integer(), non_neg_integer()}.
parse_range(~"*", Min, Max) ->
{Min, Max};
parse_range(Bin, Min, Max) ->
case binary:split(Bin, ~"-") of
[LoBin, HiBin] ->
{clamp(to_int(LoBin), Min, Max), clamp(to_int(HiBin), Min, Max)};
[ValBin] ->
V = clamp(to_int(ValBin), Min, Max),
{V, V}
end.
-spec expand_step(non_neg_integer(), non_neg_integer(), pos_integer()) -> field().
expand_step(Min, Max, Step) ->
{values, expand_step_range(Min, Max, Step)}.
-spec expand_step_range(non_neg_integer(), non_neg_integer(), pos_integer()) ->
[non_neg_integer()].
expand_step_range(Lo, Hi, Step) ->
lists:seq(Lo, Hi, Step).
-spec field_matches(field(), non_neg_integer()) -> boolean().
field_matches(star, _Value) ->
true;
field_matches({values, Values}, Value) ->
lists:member(Value, Values).
-spec to_int(binary()) -> integer().
to_int(Bin) ->
try
binary_to_integer(Bin)
catch
error:badarg -> throw({parse_error, {bad_integer, Bin}})
end.
-spec clamp(integer(), non_neg_integer(), non_neg_integer()) -> non_neg_integer().
clamp(V, Min, _Max) when V < Min -> Min;
clamp(V, _Min, Max) when V > Max -> Max;
clamp(V, _Min, _Max) -> V.
-spec validate_step(integer()) -> ok.
validate_step(Step) when Step < 1 ->
throw({parse_error, {bad_step, Step}});
validate_step(_Step) ->
ok.
%% Returns 0=Sunday, 1=Monday, ..., 6=Saturday
-spec day_of_week(integer(), 1..12, 1..31) -> 0..6.
day_of_week(Year, Month, Day) ->
calendar:day_of_the_week(Year, Month, Day) rem 7.