Add parsing of the most basic crontab entries

master
Nick Krichevsky 2022-01-22 16:54:51 -05:00
commit 53af93aaab
3 changed files with 278 additions and 0 deletions

14
Cargo.toml Normal file
View File

@ -0,0 +1,14 @@
[package]
name = "ehron"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
nom = "7.1"
thiserror = "1.0"
itertools = "0.10"
[dev-dependencies]
test-case = "1.2"

70
src/lib.rs Normal file
View File

@ -0,0 +1,70 @@
#![warn(clippy::all, clippy::pedantic)]
use std::str::FromStr;
mod parse;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CronSpecifier {
Any,
Specifically(u8),
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct CronEntry {
minute: CronSpecifier,
hour: CronSpecifier,
day_of_month: CronSpecifier,
month: CronSpecifier,
day_of_week: CronSpecifier,
}
impl FromStr for CronEntry {
type Err = parse::Error;
fn from_str(entry: &str) -> Result<Self, Self::Err> {
parse::parse_entry(entry)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::mem;
use test_case::test_case;
use CronSpecifier::{Any, Specifically};
#[test_case("* * * * *", &CronEntry{minute: Any, hour: Any, day_of_month: Any, month: Any, day_of_week: Any})]
#[test_case("0 4 * * *", &CronEntry{minute: Specifically(0), hour: Specifically(4), day_of_month: Any, month: Any, day_of_week: Any})]
#[test_case("0 4 10 5 4", &CronEntry{minute: Specifically(0), hour: Specifically(4), day_of_month: Specifically(10), month: Specifically(5), day_of_week: Specifically(4)})]
#[test_case("* * 10 5 *", &CronEntry{minute: Any, hour: Any, day_of_month: Specifically(10), month: Specifically(5), day_of_week: Any})]
fn test_successful_parse(to_parse: &str, expected: &CronEntry) {
let parse_res = CronEntry::from_str(to_parse);
match parse_res {
Ok(parsed_entry) => assert_eq!(expected, &parsed_entry),
Err(err) => panic!("Got error: '{}'", err),
}
}
#[test_case("1 2 3 4 5 6 7", &parse::Error::ExtraData("Extra data found".to_string()))]
#[test_case("* * * *", &parse::Error::NotEnoughData)]
#[test_case("* * * * a", &parse::Error::InvalidSpecifier("Parsing a letter isn't gonna work".to_string()))]
#[test_case("100 * * * *", &parse::Error::InvalidMinute(100))]
#[test_case("* * 35 * *", &parse::Error::InvalidDayOfMonth(35))]
#[test_case("* * * 13 *", &parse::Error::InvalidMonth(13))]
#[test_case("* * * * 7", &parse::Error::InvalidDayOfWeek(7))]
fn test_failed_parse(to_parse: &str, expected_error: &parse::Error) {
let parse_res = CronEntry::from_str(to_parse);
match parse_res {
Ok(_) => panic!("Parsing unexpectedly succeeded"),
Err(err) => assert_eq!(
mem::discriminant(expected_error),
mem::discriminant(&err),
"Expected error '{}', got '{}'",
expected_error,
err
),
}
}
}

194
src/parse.rs Normal file
View File

@ -0,0 +1,194 @@
use std::str::FromStr;
use itertools::Itertools;
use nom::{
branch::alt,
character::complete::{char, digit1},
combinator::map_res,
IResult,
};
use thiserror::Error;
use crate::{CronEntry, CronSpecifier};
#[derive(Debug, Clone, Error, PartialEq, Eq)]
#[non_exhaustive]
pub enum Error {
#[error("An unparsable specifier of '{0}' was given")]
InvalidSpecifier(String),
#[error("Extra data found after entry: '{0}'")]
ExtraData(String),
#[error("Not enough data to parse a full entry")]
NotEnoughData,
#[error("{0} is an out of range minute")]
InvalidMinute(u8),
#[error("{0} is an out of range hour")]
InvalidHour(u8),
#[error("{0} is an out of range day of month")]
InvalidDayOfMonth(u8),
#[error("{0} is an out of range month")]
InvalidMonth(u8),
#[error("{0} is an out of range day of week")]
InvalidDayOfWeek(u8),
}
impl From<ParseErrorAdapter> for Error {
fn from(adapter: ParseErrorAdapter) -> Self {
adapter.0
}
}
/// `ParseErrorAdapter` is used to produce [`Error`]s from [`nom::Error`]s.
/// this uses the newtype idiom like this specifically so that the
/// implementation isn't visible externally, without having to duplicate
/// the enum variants on an internal type.
struct ParseErrorAdapter(Error);
impl From<nom::error::Error<&str>> for ParseErrorAdapter {
/// When given a nom error that otherwise couldn't be converted to
/// a parse error, we will get as much of the current specifier as possible
/// and declare it an invalid specifier.
fn from(err: nom::error::Error<&str>) -> Self {
// If the failed input part is empty, the only thing we can really assume is that there wasn't
// enough data, as the parser can't really consume nothing
if err.input.is_empty() {
return Self(Error::NotEnoughData);
}
let specifier = err
.input
.chars()
.take_while(|&c| c != ' ')
.collect::<String>();
Self(Error::InvalidSpecifier(specifier))
}
}
impl From<nom::Err<nom::error::Error<&str>>> for ParseErrorAdapter {
fn from(err: nom::Err<nom::error::Error<&str>>) -> Self {
match err {
nom::Err::Incomplete(_) => {
panic!("streaming parser was used, but not expected to be. This is a bug.")
}
nom::Err::Error(underlying) | nom::Err::Failure(underlying) => underlying.into(),
}
}
}
/// `parse_entry` parses a crontab string into a `CronEntry`. These are of the
/// form of five strings matching the regular expression`(\d+|\*)`, separated by spaces
pub(crate) fn parse_entry(entry: &str) -> Result<CronEntry, Error> {
let res: IResult<&str, CronEntry> = perform_entry_parse(entry);
match res {
Ok((remaining, result)) => {
ensure_all_data_parsed(remaining, &result)?;
ensure_ranges_valid(&result)?;
Ok(result)
}
Err(err) => {
let err_adapter: ParseErrorAdapter = err.into();
Err(err_adapter.into())
}
}
}
fn ensure_all_data_parsed(remaining: &str, parsed_entry: &CronEntry) -> Result<(), Error> {
if remaining.is_empty() {
Ok(())
} else {
Err(Error::ExtraData(remaining.to_string()))
}
}
fn ensure_ranges_valid(parsed_entry: &CronEntry) -> Result<(), Error> {
let check_range = |specifier, min, max, error_variant: fn(u8) -> Error| match specifier {
CronSpecifier::Any => Ok(()),
CronSpecifier::Specifically(value) => {
if value >= min && value <= max {
Ok(())
} else {
Err(error_variant(value))
}
}
};
check_range(parsed_entry.minute, 0, 59, Error::InvalidMinute)?;
check_range(parsed_entry.hour, 0, 23, Error::InvalidHour)?;
check_range(parsed_entry.day_of_month, 1, 31, Error::InvalidDayOfMonth)?;
check_range(parsed_entry.month, 1, 12, Error::InvalidMonth)?;
check_range(parsed_entry.day_of_week, 0, 6, Error::InvalidDayOfWeek)?;
Ok(())
}
fn perform_entry_parse(entry: &str) -> IResult<&str, CronEntry> {
let (remaining, specifiers) = separated_five_tuple(char(' '), parse_specifier)(entry)?;
let parsed_entry = CronEntry {
minute: specifiers.0,
hour: specifiers.1,
day_of_month: specifiers.2,
month: specifiers.3,
day_of_week: specifiers.4,
};
Ok((remaining, parsed_entry))
}
fn parse_specifier(chunk: &str) -> IResult<&str, CronSpecifier> {
alt((parse_star_specifier, parse_numeric_specifier))(chunk)
}
fn parse_star_specifier(chunk: &str) -> IResult<&str, CronSpecifier> {
let (remaining, _) = char('*')(chunk)?;
Ok((remaining, CronSpecifier::Any))
}
fn parse_numeric_specifier(chunk: &str) -> IResult<&str, CronSpecifier> {
map_res::<_, _, _, _, <u8 as FromStr>::Err, _, _>(digit1, |raw_num: &str| {
let num = raw_num.parse()?;
Ok(CronSpecifier::Specifically(num))
})(chunk)
}
/// Parse five elements separated by some kind of constant specifier.
/// This is similar to [`nom::sequence::separated_pair`], but with five elements, specifically
fn separated_five_tuple<I, OS, OP, E, S, P>(
mut sep: S,
mut parser: P,
) -> impl FnMut(I) -> IResult<I, (OP, OP, OP, OP, OP), E>
where
E: nom::error::ParseError<I>,
S: nom::Parser<I, OS, E>,
P: nom::Parser<I, OP, E>,
{
const NUM_ELEMENTS: usize = 5;
move |data: I| {
// TODO: This heap allocation is a bit overkill, and this could likely be
// done with some unsafe array initialization, but I wasn't sure how to ensure
// that I handled the dropping properly, so I shiyed waay from it.
let mut parser_outputs = Vec::with_capacity(NUM_ELEMENTS);
let mut remaining = data;
for i in 0..NUM_ELEMENTS {
let (remaining_from_parser, parsed) = parser.parse(remaining)?;
remaining = remaining_from_parser;
parser_outputs.push(parsed);
if i != NUM_ELEMENTS - 1 {
let (remaining_from_sep, _) = sep.parse(remaining)?;
remaining = remaining_from_sep;
}
}
assert_eq!(NUM_ELEMENTS, parser_outputs.len());
let res_tuple = parser_outputs
.into_iter()
.collect_tuple::<(OP, OP, OP, OP, OP)>()
.expect("not enough elements to pack into tuple");
Ok((remaining, res_tuple))
}
}