Repository: kevincianfarini/cardiologist (kotlin)

Issue:
Implement cron expression parsing
The cron expressions we should support are: 

* `*` representing any value.
* `x,y` specifying two values -- `x` and `y`
* `a-b` specifying all values from a to b inclusive. 
* `a-b,x,y` specifying a through b inclusive, x, and y. 
* ~`/` denoting step values.~ We won't actually support these since they're not standard cron. 

We should explicitly not support any non-standard cron expressions like `@monthly`.

Pull request description:
References #65
Closes #91

Patch:
diff --git a/src/commonMain/kotlin/io/github/kevincianfarini/cardiologist/impl/cron.kt b/src/commonMain/kotlin/io/github/kevincianfarini/cardiologist/impl/cron.kt
new file mode 100644
index 0000000..fa784f6
--- /dev/null
+++ b/src/commonMain/kotlin/io/github/kevincianfarini/cardiologist/impl/cron.kt
@@ -0,0 +1,168 @@
+package io.github.kevincianfarini.cardiologist.impl
+
+import io.github.kevincianfarini.cardiologist.PulseSchedule
+import kotlinx.datetime.DayOfWeek
+import kotlinx.datetime.Month
+
+internal fun String.parseCronExpression(): PulseSchedule {
+    val segments = split(Regex("\\s+"))
+    require(segments.size == 5) { "'$this' is not a valid cron expression." }
+    val minutes = segments[0].parseMinutesExpressionOrNull()
+    val hours = segments[1].parseHoursExpressionOrNull()
+    val daysOfMonth = segments[2].parseDaysOfMonthExpressionOrNull()
+    val months = segments[3].parseMonthsExpressionOrNull()
+    val daysOfWeek = segments[4].parseDaysOfWeekExpressionOrNull()
+    return if (minutes != null && hours != null && daysOfMonth != null && months != null && daysOfWeek != null) {
+        PulseSchedule(
+            atSeconds = setOf(0),
+            atMinutes = minutes,
+            atHours = hours,
+            onDaysOfMonth = daysOfMonth,
+            onDaysOfWeek = daysOfWeek,
+            inMonths = months
+        )
+    } else {
+        val errorMessage = buildMalformedCronErrorMessage(
+            segments = segments,
+            erroredSegmentIndices = listOfNotNull(
+                0.takeIf { minutes == null },
+                1.takeIf { hours == null },
+                2.takeIf { daysOfMonth == null },
+                3.takeIf { months == null },
+                4.takeIf { daysOfWeek == null },
+            )
+        )
+        throw IllegalArgumentException(errorMessage)
+    }
+}
+
+private fun String.parseMinutesExpressionOrNull(): Set<Int>? = parseIntegerComponentExpressionOrNull(
+    wildcardValue = (0..59).toSet(),
+    validValues = 0..59,
+)
+
+private fun String.parseHoursExpressionOrNull(): Set<Int>? = parseIntegerComponentExpressionOrNull(
+    wildcardValue = (0..23).toSet(),
+    validValues = 0..23,
+)
+
+private fun String.parseDaysOfMonthExpressionOrNull(): Set<Int>? = parseIntegerComponentExpressionOrNull(
+    wildcardValue = (1..31).toSet(),
+    validValues = 1..31,
+)
+
+private fun String.parseDaysOfWeekExpressionOrNull(): Set<DayOfWeek>? {
+    val raw = parseIntegerComponentExpressionOrNull(
+        wildcardValue = emptySet(), // A wildcard for the day of week cron expression is an empty set.
+        validValues = 0..6,
+        stringToIntegerMapping = mapOf(
+            "SUN" to 0,
+            "MON" to 1,
+            "TUE" to 2,
+            "WED" to 3,
+            "THU" to 4,
+            "FRI" to 5,
+            "SAT" to 6,
+        )
+    )
+    return raw?.let { numbers ->
+        buildSet {
+            numbers.forEach { dayOfWeekInt ->
+                val result = when (dayOfWeekInt) {
+                    0 -> DayOfWeek.SUNDAY
+                    1 -> DayOfWeek.MONDAY
+                    2 -> DayOfWeek.TUESDAY
+                    3 -> DayOfWeek.WEDNESDAY
+                    4 -> DayOfWeek.THURSDAY
+                    5 -> DayOfWeek.FRIDAY
+                    6 -> DayOfWeek.SATURDAY
+                    else -> error("Invalid day of week integer $dayOfWeekInt.")
+                }
+                add(result)
+            }
+        }
+    }
+}
+
+private fun String.parseMonthsExpressionOrNull(): Set<Month>? {
+    val raw = parseIntegerComponentExpressionOrNull(
+        wildcardValue = (1..12).toSet(),
+        validValues = 1..12,
+        stringToIntegerMapping = mapOf(
+            "JAN" to 1,
+            "FEB" to 2,
+            "MAR" to 3,
+            "APR" to 4,
+            "MAY" to 5,
+            "JUN" to 6,
+            "JUL" to 7,
+            "AUG" to 8,
+            "SEP" to 9,
+            "OCT" to 10,
+            "NOV" to 11,
+            "DEC" to 12,
+        )
+    )
+    return raw?.let { numbers ->
+        buildSet {
+            numbers.forEach { monthNumber ->
+                add(Month(monthNumber))
+            }
+        }
+    }
+}
+
+private fun String.parseIntegerComponentExpressionOrNull(
+    wildcardValue: Set<Int>,
+    validValues: IntRange,
+    stringToIntegerMapping: Map<String, Int> = emptyMap(),
+): Set<Int>? = when {
+    this == "*" -> wildcardValue
+    "," in this -> {
+        val parts = split(",").map { it.parseIntegerComponentExpressionOrNull(wildcardValue, validValues) }
+        val noNulls = parts.filterNotNull()
+        if (parts.size == noNulls.size) {
+            buildSet {
+                noNulls.forEach { part -> addAll(part) }
+            }
+        } else {
+            null
+        }
+    }
+    "-" in this -> {
+        val parts = split("-")
+        if (parts.size == 2) {
+            val start = parts[0].toIntOrNull() ?: stringToIntegerMapping[parts[0].uppercase()]
+            val end = parts[1].toIntOrNull() ?: stringToIntegerMapping[parts[1].uppercase()]
+            if (start == null || end == null) {
+                null
+            } else {
+                (start..end).takeIf { !it.isEmpty() }?.toSet()
+            }
+        } else {
+            // Because we recurse on commas first, we guarantee that an inclusive range will always only be parsed as
+            // a single value in this branch of code. Therefore, if there's more than two parts it's invalid.
+            null
+        }
+    }
+    else -> (toIntOrNull() ?: stringToIntegerMapping[uppercase()])?.takeIf { it in validValues }?.let {
+        // Base case is just a single integer. If it can't be coerced to an Int it's invalid.
+        setOf(it)
+    }
+}
+
+private fun buildMalformedCronErrorMessage(segments: List<String>, erroredSegmentIndices: List<Int>): String {
+    return buildString {
+        append("Cron expression is malformed:")
+        segments.forEachIndexed{ index, segment ->
+            append(" ")
+            if (index in erroredSegmentIndices) {
+                append("<<")
+            }
+            append(segment)
+            if (index in erroredSegmentIndices) {
+                append(">>")
+            }
+        }
+    }
+}
\ No newline at end of file


Test patch:
diff --git a/src/commonTest/kotlin/io/github/kevincianfarini/cardiologist/impl/ParseCronTest.kt b/src/commonTest/kotlin/io/github/kevincianfarini/cardiologist/impl/ParseCronTest.kt
new file mode 100644
index 0000000..0aef1a7
--- /dev/null
+++ b/src/commonTest/kotlin/io/github/kevincianfarini/cardiologist/impl/ParseCronTest.kt
@@ -0,0 +1,343 @@
+package io.github.kevincianfarini.cardiologist.impl
+
+import io.github.kevincianfarini.cardiologist.PulseSchedule
+import kotlinx.datetime.DayOfWeek
+import kotlinx.datetime.Month
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFails
+
+class ParseCronTest {
+
+    @Test
+    fun incorrect_number_of_segments_errors() {
+        val e = assertFails { "".parseCronExpression() }
+        assertEquals(
+            expected = "'' is not a valid cron expression.",
+            actual = e.message
+        )
+    }
+
+    @Test
+    fun all_wildcard_segments_returns_correct_value() {
+        assertEquals(
+            expected = PulseSchedule(
+                atSeconds = setOf(0),
+                atMinutes = (0..59).toSet(),
+                atHours = (0..23).toSet(),
+                onDaysOfMonth = (1..31).toSet(),
+                onDaysOfWeek = emptySet(),
+                inMonths = Month.entries.toSet(),
+            ),
+            actual = "* * * * *".parseCronExpression()
+        )
+    }
+
+    @Test
+    fun arbitrary_whitespace_does_not_matter() {
+        assertEquals(
+            expected = PulseSchedule(
+                atSeconds = setOf(0),
+                atMinutes = (0..59).toSet(),
+                atHours = (0..23).toSet(),
+                onDaysOfMonth = (1..31).toSet(),
+                onDaysOfWeek = emptySet(),
+                inMonths = Month.entries.toSet(),
+            ),
+            actual = "*       * \t *   *   *".parseCronExpression()
+        )
+    }
+
+    @Test
+    fun minutes_base_case() {
+        assertEquals(
+            expected = PulseSchedule(
+                atSeconds = setOf(0),
+                atMinutes = setOf(0),
+                atHours = (0..23).toSet(),
+                onDaysOfMonth = (1..31).toSet(),
+                onDaysOfWeek = emptySet(),
+                inMonths = Month.entries.toSet(),
+            ),
+            actual = "0 * * * *".parseCronExpression()
+        )
+    }
+
+    @Test
+    fun minutes_multiple_values() {
+        assertEquals(
+            expected = PulseSchedule(
+                atSeconds = setOf(0),
+                atMinutes = setOf(0, 5),
+                atHours = (0..23).toSet(),
+                onDaysOfMonth = (1..31).toSet(),
+                onDaysOfWeek = emptySet(),
+                inMonths = Month.entries.toSet(),
+            ),
+            actual = "0,5 * * * *".parseCronExpression()
+        )
+    }
+
+    @Test
+    fun minutes_range_values() {
+        assertEquals(
+            expected = PulseSchedule(
+                atSeconds = setOf(0),
+                atMinutes = setOf(0, 1, 2, 3, 4, 5),
+                atHours = (0..23).toSet(),
+                onDaysOfMonth = (1..31).toSet(),
+                onDaysOfWeek = emptySet(),
+                inMonths = Month.entries.toSet(),
+            ),
+            actual = "0-5 * * * *".parseCronExpression()
+        )
+    }
+
+    @Test
+    fun minutes_mixes_comma_range_values() {
+        assertEquals(
+            expected = PulseSchedule(
+                atSeconds = setOf(0),
+                atMinutes = setOf(0, 1, 2, 3, 4, 5, 10, 15),
+                atHours = (0..23).toSet(),
+                onDaysOfMonth = (1..31).toSet(),
+                onDaysOfWeek = emptySet(),
+                inMonths = Month.entries.toSet(),
+            ),
+            actual = "0-5,10,15 * * * *".parseCronExpression()
+        )
+    }
+
+    @Test
+    fun single_comma_two_ranges() {
+        assertEquals(
+            expected = PulseSchedule(
+                atSeconds = setOf(0),
+                atMinutes = setOf(0, 1, 2, 3, 4, 5, 10, 11, 12, 13, 14, 15),
+                atHours = (0..23).toSet(),
+                onDaysOfMonth = (1..31).toSet(),
+                onDaysOfWeek = emptySet(),
+                inMonths = Month.entries.toSet(),
+            ),
+            actual = "0-5,10-15 * * * *".parseCronExpression()
+        )
+    }
+
+    @Test
+    fun minutes_double_comma_invalid() {
+        val e = assertFails {
+            "0,,1 * * * *".parseCronExpression()
+        }
+        assertEquals(
+            expected = "Cron expression is malformed: <<0,,1>> * * * *",
+            actual = e.message,
+        )
+    }
+
+    @Test
+    fun minutes_double_range_invalid() {
+        val e = assertFails {
+            "0--1 * * * *".parseCronExpression()
+        }
+        assertEquals(
+            expected = "Cron expression is malformed: <<0--1>> * * * *",
+            actual = e.message,
+        )
+    }
+
+    @Test
+    fun minutes_range_no_end_invalid() {
+        val e = assertFails {
+            "0- * * * *".parseCronExpression()
+        }
+        assertEquals(
+            expected = "Cron expression is malformed: <<0->> * * * *",
+            actual = e.message,
+        )
+    }
+
+    @Test
+    fun minutes_range_no_start_invalid() {
+        val e = assertFails {
+            "-1 * * * *".parseCronExpression()
+        }
+        assertEquals(
+            expected = "Cron expression is malformed: <<-1>> * * * *",
+            actual = e.message,
+        )
+    }
+
+    @Test
+    fun minutes_range_empty_invalid() {
+        val e = assertFails {
+            "5-3 * * * *".parseCronExpression()
+        }
+        assertEquals(
+            expected = "Cron expression is malformed: <<5-3>> * * * *",
+            actual = e.message,
+        )
+    }
+
+    @Test
+    fun minutes_invalid_value() {
+        val e = assertFails {
+            "75 * * * *".parseCronExpression()
+        }
+        assertEquals(
+            expected = "Cron expression is malformed: <<75>> * * * *",
+            actual = e.message,
+        )
+    }
+
+
+    @Test
+    fun hours_invalid_value() {
+        val e = assertFails {
+            "* 24 * * *".parseCronExpression()
+        }
+        assertEquals(
+            expected = "Cron expression is malformed: * <<24>> * * *",
+            actual = e.message,
+        )
+    }
+
+    @Test
+    fun day_of_month_invalid_value() {
+        val e = assertFails {
+            "* * 0 * *".parseCronExpression()
+        }
+        assertEquals(
+            expected = "Cron expression is malformed: * * <<0>> * *",
+            actual = e.message,
+        )
+    }
+
+    @Test
+    fun month_invalid_int_value() {
+        val e = assertFails {
+            "* * * 13 *".parseCronExpression()
+        }
+        assertEquals(
+            expected = "Cron expression is malformed: * * * <<13>> *",
+            actual = e.message,
+        )
+    }
+
+    @Test
+    fun day_of_week_invalid_int_value() {
+        val e = assertFails {
+            "* * * * 7".parseCronExpression()
+        }
+        assertEquals(
+            expected = "Cron expression is malformed: * * * * <<7>>",
+            actual = e.message,
+        )
+    }
+
+    @Test
+    fun month_names_map_to_values_properly() {
+        // Spongebob case them because these are case-insensitive.
+        val months = mapOf(
+            "jan" to Month.JANUARY,
+            "feB" to Month.FEBRUARY,
+            "mAR" to Month.MARCH,
+            "APR" to Month.APRIL,
+            "MAy" to Month.MAY,
+            "Jun" to Month.JUNE,
+            "jul" to Month.JULY,
+            "AUG" to Month.AUGUST,
+            "sep" to Month.SEPTEMBER,
+            "Oct" to Month.OCTOBER,
+            "NOV" to Month.NOVEMBER,
+            "dec" to Month.DECEMBER,
+        )
+        months.forEach { (string, enum) ->
+            assertEquals(
+                expected = PulseSchedule(
+                    atSeconds = setOf(0),
+                    atMinutes = (0..59).toSet(),
+                    atHours = (0..23).toSet(),
+                    onDaysOfMonth = (1..31).toSet(),
+                    onDaysOfWeek = emptySet(),
+                    inMonths = setOf(enum),
+                ),
+                actual = "* * * $string *".parseCronExpression()
+            )
+        }
+    }
+
+    @Test
+    fun days_of_week_map_to_values_propertly() {
+        // Spongebob case them because these are case-insensitive.
+        val days = mapOf(
+            "mon" to DayOfWeek.MONDAY,
+            "tuE" to DayOfWeek.TUESDAY,
+            "wED" to DayOfWeek.WEDNESDAY,
+            "THU" to DayOfWeek.THURSDAY,
+            "FRi" to DayOfWeek.FRIDAY,
+            "Sat" to DayOfWeek.SATURDAY,
+            "sun" to DayOfWeek.SUNDAY,
+        )
+        days.forEach { (string, enum) ->
+            assertEquals(
+                expected = PulseSchedule(
+                    atSeconds = setOf(0),
+                    atMinutes = (0..59).toSet(),
+                    atHours = (0..23).toSet(),
+                    onDaysOfMonth = (1..31).toSet(),
+                    onDaysOfWeek = setOf(enum),
+                    inMonths = Month.entries.toSet()
+                ),
+                actual = "* * * * $string".parseCronExpression()
+            )
+        }
+    }
+
+    @Test
+    fun full_range_day_of_week_maps_properly() {
+        assertEquals(
+            expected = PulseSchedule(
+                atSeconds = setOf(0),
+                atMinutes = (0..59).toSet(),
+                atHours = (0..23).toSet(),
+                onDaysOfMonth = (1..31).toSet(),
+                onDaysOfWeek = DayOfWeek.entries.toSet(),
+                inMonths = Month.entries.toSet()
+            ),
+            actual = "* * * * SUN-SAT".parseCronExpression()
+        )
+    }
+
+    @Test
+    fun invalid_month_string() {
+        val e = assertFails {
+            "* * * WRONG *".parseCronExpression()
+        }
+        assertEquals(
+            expected = "Cron expression is malformed: * * * <<WRONG>> *",
+            actual = e.message,
+        )
+    }
+
+    @Test
+    fun invalid_day_of_week_string() {
+        val e = assertFails {
+            "* * * * WRONG".parseCronExpression()
+        }
+        assertEquals(
+            expected = "Cron expression is malformed: * * * * <<WRONG>>",
+            actual = e.message,
+        )
+    }
+
+    @Test
+    fun multiple_incorrect_values_yield_many_errors() {
+        val e = assertFails {
+            "60 24 32 13 7".parseCronExpression()
+        }
+        assertEquals(
+            expected = "Cron expression is malformed: <<60>> <<24>> <<32>> <<13>> <<7>>",
+            actual = e.message,
+        )
+    }
+}
\ No newline at end of file