Last active
May 2, 2025 14:52
-
-
Save Badgerati/19f2721bc5bf9222417d36362b04d9e2 to your computer and use it in GitHub Desktop.
PowerShell cron expression parser, to check if a date/time matches a cron expression
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<# | |
.DESCRIPTION | |
PowerShell cron expression parser, to check if a date/time matches a cron expression | |
Format: | |
<min> <hour> <day-of-month> <month> <day-of-week> | |
.PARAMETER Expression | |
A cron expression to validate | |
.PARAMETER DateTime | |
[Optional] A specific date/time to check cron expression against. (Default: DateTime.Now) | |
.EXAMPLE Test expression against the current date/time | |
Test-CronExpression -Expression '5/7 * 29 FEB,MAR *' | |
.EXAMPLE Test expression against a specific date/time | |
Test-CronExpression -Expression '5/7 * 29 FEB,MAR *' -DateTime ([DateTime]::Now) | |
#> | |
param ( | |
[Parameter(Mandatory=$true)] | |
[ValidateNotNullOrEmpty()] | |
[string] | |
$Expression, | |
[Parameter()] | |
$DateTime = $null | |
) | |
function Get-CronFields | |
{ | |
return @( | |
'Minute', | |
'Hour', | |
'DayOfMonth', | |
'Month', | |
'DayOfWeek' | |
) | |
} | |
function Get-CronFieldConstraints | |
{ | |
return @{ | |
'MinMax' = @( | |
@(0, 59), | |
@(0, 23), | |
@(1, 31), | |
@(1, 12), | |
@(0, 6) | |
); | |
'DaysInMonths' = @( | |
31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 | |
); | |
'Months' = @( | |
'January', 'February', 'March', 'April', 'May', 'June', 'July', | |
'August', 'September', 'October', 'November', 'December' | |
) | |
} | |
} | |
function Get-CronPredefined | |
{ | |
return @{ | |
# normal | |
'@minutely' = '* * * * *'; | |
'@hourly' = '0 * * * *'; | |
'@daily' = '0 0 * * *'; | |
'@weekly' = '0 0 * * 0'; | |
'@monthly' = '0 0 1 * *'; | |
'@quarterly' = '0 0 1 1,4,7,10'; | |
'@yearly' = '0 0 1 1 *'; | |
'@annually' = '0 0 1 1 *'; | |
# twice | |
'@semihourly' = '0,30 * * * *'; | |
'@semidaily' = '0 0,12 * * *'; | |
'@semiweekly' = '0 0 * * 0,4'; | |
'@semimonthly' = '0 0 1,15 * *'; | |
'@semiyearly' = '0 0 1 1,6 *'; | |
'@semiannually' = '0 0 1 1,6 *'; | |
} | |
} | |
function Get-CronFieldAliases | |
{ | |
return @{ | |
'Month' = @{ | |
'Jan' = 1; | |
'Feb' = 2; | |
'Mar' = 3; | |
'Apr' = 4; | |
'May' = 5; | |
'Jun' = 6; | |
'Jul' = 7; | |
'Aug' = 8; | |
'Sep' = 9; | |
'Oct' = 10; | |
'Nov' = 11; | |
'Dec' = 12; | |
}; | |
'DayOfWeek' = @{ | |
'Sun' = 0; | |
'Mon' = 1; | |
'Tue' = 2; | |
'Wed' = 3; | |
'Thu' = 4; | |
'Fri' = 5; | |
'Sat' = 6; | |
}; | |
} | |
} | |
function ConvertFrom-CronExpression | |
{ | |
param ( | |
[Parameter(Mandatory=$true)] | |
[ValidateNotNullOrEmpty()] | |
[string] | |
$Expression | |
) | |
$Expression = $Expression.Trim() | |
# check predefineds | |
$predef = Get-CronPredefined | |
if ($null -ne $predef[$Expression]) { | |
$Expression = $predef[$Expression] | |
} | |
# split and check atoms length | |
$atoms = @($Expression -isplit '\s+') | |
if ($atoms.Length -ne 5) { | |
throw "Cron expression should only consist of 5 parts: $($Expression)" | |
} | |
# basic variables | |
$aliasRgx = '(?<tag>[a-z]{3})' | |
# get cron obj and validate atoms | |
$fields = Get-CronFields | |
$constraints = Get-CronFieldConstraints | |
$aliases = Get-CronFieldAliases | |
$cron = @{} | |
for ($i = 0; $i -lt $atoms.Length; $i++) | |
{ | |
$_cronExp = @{ | |
'Range' = $null; | |
'Values' = $null; | |
} | |
$_atom = $atoms[$i] | |
$_field = $fields[$i] | |
$_constraint = $constraints.MinMax[$i] | |
$_aliases = $aliases[$_field] | |
# replace day of week and months with numbers | |
switch ($_field) | |
{ | |
{ $_field -ieq 'month' -or $_field -ieq 'dayofweek' } | |
{ | |
while ($_atom -imatch $aliasRgx) { | |
$_alias = $_aliases[$Matches['tag']] | |
if ($null -eq $_alias) { | |
throw "Invalid $($_field) alias found: $($Matches['tag'])" | |
} | |
$_atom = $_atom -ireplace $Matches['tag'], $_alias | |
$_atom -imatch $aliasRgx | Out-Null | |
} | |
} | |
} | |
# ensure atom is a valid value | |
if (!($_atom -imatch '^[\d|/|*|\-|,]+$')) { | |
throw "Invalid atom character: $($_atom)" | |
} | |
# replace * with min/max constraint | |
$_atom = $_atom -ireplace '\*', ($_constraint -join '-') | |
# parse the atom for either a literal, range, array, or interval | |
# literal | |
if ($_atom -imatch '^\d+$') { | |
$_cronExp.Values = @([int]$_atom) | |
} | |
# range | |
elseif ($_atom -imatch '^(?<min>\d+)\-(?<max>\d+)$') { | |
$_cronExp.Range = @{ 'Min' = [int]($Matches['min'].Trim()); 'Max' = [int]($Matches['max'].Trim()); } | |
} | |
# array | |
elseif ($_atom -imatch '^[\d,]+$') { | |
$_cronExp.Values = [int[]](@($_atom -split ',').Trim()) | |
} | |
# interval | |
elseif ($_atom -imatch '(?<start>(\d+|\*))\/(?<interval>\d+)$') { | |
$start = $Matches['start'] | |
$interval = [int]$Matches['interval'] | |
if ($interval -ieq 0) { | |
$interval = 1 | |
} | |
if ([string]::IsNullOrWhiteSpace($start) -or $start -ieq '*') { | |
$start = 0 | |
} | |
$start = [int]$start | |
$_cronExp.Values = @($start) | |
$next = $start + $interval | |
while ($next -le $_constraint[1]) { | |
$_cronExp.Values += $next | |
$next += $interval | |
} | |
} | |
# error | |
else { | |
throw "Invalid cron atom format found: $($_atom)" | |
} | |
# ensure cron expression values are valid | |
if ($null -ne $_cronExp.Range) { | |
if ($_cronExp.Range.Min -gt $_cronExp.Range.Max) { | |
throw "Min value for $($_field) should not be greater than the max value" | |
} | |
if ($_cronExp.Range.Min -lt $_constraint[0]) { | |
throw "Min value '$($_cronExp.Range.Min)' for $($_field) is invalid, should be greater than/equal to $($_constraint[0])" | |
} | |
if ($_cronExp.Range.Max -gt $_constraint[1]) { | |
throw "Max value '$($_cronExp.Range.Max)' for $($_field) is invalid, should be less than/equal to $($_constraint[1])" | |
} | |
} | |
if ($null -ne $_cronExp.Values) { | |
$_cronExp.Values | ForEach-Object { | |
if ($_ -lt $_constraint[0] -or $_ -gt $_constraint[1]) { | |
throw "Value '$($_)' for $($_field) is invalid, should be between $($_constraint[0]) and $($_constraint[1])" | |
} | |
} | |
} | |
# assign value | |
$cron[$_field] = $_cronExp | |
} | |
# post validation for month/days in month | |
if ($null -ne $cron['Month'].Values -and $null -ne $cron['DayOfMonth'].Values) | |
{ | |
foreach ($mon in $cron['Month'].Values) { | |
foreach ($day in $cron['DayOfMonth'].Values) { | |
if ($day -gt $constraints.DaysInMonths[$mon - 1]) { | |
throw "$($constraints.Months[$mon - 1]) only has $($constraints.DaysInMonths[$mon - 1]) days, but $($day) was supplied" | |
} | |
} | |
} | |
} | |
# return the parsed cron expression | |
return $cron | |
} | |
function Test-RangeAndValue($AtomContraint, $NowValue) { | |
if ($null -ne $AtomContraint.Range) { | |
if ($NowValue -lt $AtomContraint.Range.Min -or $NowValue -gt $AtomContraint.Range.Max) { | |
return $false | |
} | |
} | |
elseif ($AtomContraint.Values -inotcontains $NowValue) { | |
return $false | |
} | |
return $true | |
} | |
# current time | |
if ($null -eq $DateTime) { | |
$DateTime = [datetime]::Now | |
} | |
# convert the expression | |
$Atoms = ConvertFrom-CronExpression -Expression $Expression | |
# check day of month | |
if (!(Test-RangeAndValue -AtomContraint $Atoms.DayOfMonth -NowValue $DateTime.Day)) { | |
return $false | |
} | |
# check day of week | |
if (!(Test-RangeAndValue -AtomContraint $Atoms.DayOfWeek -NowValue ([int]$DateTime.DayOfWeek))) { | |
return $false | |
} | |
# check month | |
if (!(Test-RangeAndValue -AtomContraint $Atoms.Month -NowValue $DateTime.Month)) { | |
return $false | |
} | |
# check hour | |
if (!(Test-RangeAndValue -AtomContraint $Atoms.Hour -NowValue $DateTime.Hour)) { | |
return $false | |
} | |
# check minute | |
if (!(Test-RangeAndValue -AtomContraint $Atoms.Minute -NowValue $DateTime.Minute)) { | |
return $false | |
} | |
# date is valid | |
return $true |
@Badgerati I tested this nice script just now and could not quite get it to work with some of my cron schedule expressions. I took a closer look at the code and there are some logical 'bugs' related to ranges of numbers and lists in the expressions. For example, consider odd numbers, e.g. "At every 2nd minute from 1 through 59" using expression "1-59/2 * * * *" or a more complex "At minute 23 past every 2nd hour from 0 through 20 and 4" using expression "23 0-20/2,4 * * *". Well, it just did not work. But I forked and did some changes that seems to work for me. You can find my updates in the gist below. Please feel free to test and include my updates in your script:
https://gist.github.com/reijoh/fffa5642272fdc84d4b7a01691e91795
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
The regex on line 198 is what I updated. I've ended up moving my scheduling needs into Jenkins after trying to get too fancy with things.