// SPDX-License-Identifier: AGPL-3.0-or-later //! Nom parsers used within the parsing steps. use nom::{ branch::alt, bytes::complete::{tag, take, take_till, take_until, take_while}, character::complete::{char, multispace0}, combinator::{map, map_res, opt, rest}, number::complete::float, sequence::{delimited, pair, preceded, separated_pair, tuple}, IResult, }; use crate::{CourseOffering, Trimester}; /// Determines if the provided character is an ascii digit. const fn is_decimal_digit(c: char) -> bool { c.is_ascii_digit() } /// Parses a string slice into a [`u16`]. /// /// # Errors /// /// This function will return an error if the string cannot be parsed. fn from_decimal(input: &str) -> Result { input.parse::() } /// Retrieves all the digits from a CRN and maps them to a [`u16`]. /// /// # Errors /// /// This function will return an error if nom cannot parse the input. fn crn_digits(input: &str) -> IResult<&str, u16> { map_res(take_while(is_decimal_digit), from_decimal)(input) } /// Parses a course reference number. /// /// # Errors /// /// This function will return an error if nom cannot parse the input. pub fn course_reference_number(input: &str) -> IResult<&str, u16> { preceded(pair(tag("CRN"), multispace0), crn_digits)(input) } /// Parser that parses a timetable separator. /// /// # Errors /// /// This function will return an error if input does not match expected format by nom. pub fn timetable_separator(input: &str) -> IResult<&str, char> { delimited(multispace0, char('\u{2022}'), multispace0)(input) } /// Parses a trimester from an input. /// /// # Errors /// /// This function will return an error if input does not match expected format by nom. pub fn trimester(input: &str) -> IResult<&str, Trimester> { map_res( alt(( tag("block dates/3"), tag("part year/3"), tag("full year"), take_till(char::is_whitespace), )), Trimester::try_from, )(input) } /// Parses a course offering from an input. /// /// # Errors /// /// This function will return an error if . pub fn offering(input: &str) -> IResult<&str, CourseOffering> { let (input, (trimester, crn)) = separated_pair(trimester, timetable_separator, course_reference_number)(input)?; Ok((input, CourseOffering::new(crn, trimester))) } /// Parses a course title from an input. /// /// # Errors /// /// This function will return an error if nom is unable to parse the input. This should only happen /// if the input is less than 8 characters. pub fn title(input: &str) -> IResult<&str, &str> { take(8usize)(input) } /// Parses a course subtitle from an input. /// /// # Errors /// /// This function will return an error if the subtitle is not preceded by "`\u{2013}` ". pub fn subtitle(input: &str) -> IResult<&str, &str> { preceded(tag("\u{2013} "), rest)(input) } /// Parses course prerequisites from an input. /// /// # Errors /// /// This function will return an error if the input is not preceded by (P). pub fn prerequisites(input: &str) -> IResult<&str, &str> { map( preceded( tag("(P)"), alt((take_until("(C)"), take_until("(X)"), rest)), ), // The data will often end up with leading and trailing spaces. Trimming is the easiest // way to get rid of these. str::trim, )(input) } /// Parses course corequisites from an input. /// /// # Errors /// /// This function will return an error if the input is not preceded by (C). pub fn corequisites(input: &str) -> IResult<&str, &str> { map( preceded(tag("(C)"), alt((take_until("(X)"), rest))), // The data will often end up with leading and trailing spaces. Trimming is the easiest // way to get rid of these. str::trim, )(input) } /// Parses course restrictions from an input. /// /// # Errors /// /// This function will return an error if the input is not preceded by (X). pub fn restrictions(input: &str) -> IResult<&str, &str> { map( preceded(tag("(X)"), rest), // The data will often end up with leading and trailing spaces. Trimming is the easiest // way to get rid of these. str::trim, )(input) } /// Alias for the return type of the requirements parser. type RequirementsReturn<'a> = (Option<&'a str>, Option<&'a str>, Option<&'a str>); /// Parses course requirements from an input. /// /// # Errors /// /// This function should not return an error, and errors are to be considered unreachable. pub fn requirements(input: &str) -> IResult<&str, RequirementsReturn> { tuple((opt(prerequisites), opt(corequisites), opt(restrictions)))(input) } /// Parses the course points from an input. /// /// # Errors /// /// This function will return an error if the input provided does not contain a float. pub fn course_points(input: &str) -> IResult<&str, f32> { float(input) } /// Parses the entire "coursepoints" section of the course offering. /// /// # Errors /// /// This function will return an error if the course points cannot be parsed. pub fn course_offering(input: &str) -> IResult<&str, (f32, Option)> { tuple(( course_points, opt(preceded(tag(" pts \u{2022} "), requirements)), ))(input) } #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests { use super::*; #[test] fn crn_parser_basic() { assert_eq!(course_reference_number("CRN 5912").unwrap().1, 5912); assert_eq!(course_reference_number("CRN 17146").unwrap().1, 17146); } #[test] fn crn_parser_postfix() { assert_eq!( course_reference_number("CRN 331 [Distance]").unwrap().1, 331 ); } #[test] fn crn_parser_extra_whitespace() { assert_eq!(course_reference_number("CRN 8913 ").unwrap().1, 8913); assert_eq!(course_reference_number("CRN 61151").unwrap().1, 61151); } #[test] fn crn_parser_no_whitespace() { assert_eq!(course_reference_number("CRN615").unwrap().1, 615); } #[test] fn offering_parser_alphabetic_durations() { let block_dates = offering("block dates/3 \u{2022} CRN 25341").unwrap(); assert_eq!(block_dates.1.course_reference_number, 25341); assert_eq!(block_dates.1.trimester, Trimester::BlockDates); let part_year = offering("part year/3 \u{2022} CRN 1816").unwrap(); assert_eq!(part_year.1.course_reference_number, 1816); assert_eq!(part_year.1.trimester, Trimester::PartYear); let full_year = offering("full year \u{2022} CRN 19175").unwrap(); assert_eq!(full_year.1.course_reference_number, 19175); assert_eq!(full_year.1.trimester, Trimester::FullYear); } #[test] fn title_parser() { assert_eq!(title("HELT 502 ").unwrap().1, "HELT 502"); } #[test] fn subtitle_parser() { let parsed_subtitle = subtitle("\u{2013} Identification, Assessment and Control of Hazards and Risks") .unwrap(); // The dash and space were parsed, and are not remaining data. assert_eq!(parsed_subtitle.0, ""); // The actual subtitle. assert_eq!( parsed_subtitle.1, "Identification, Assessment and Control of Hazards and Risks" ); } #[test] fn prereq_parser() { let parsed_prereq = prerequisites("(P) LING 123 (C) SOPH 184 (X) SOPH 185").unwrap(); assert_eq!(parsed_prereq.1, "LING 123"); } #[test] fn req_parser() { let parsed_prereq = requirements("(P) LING 229, LING 228; (C) MATH 883").unwrap(); assert_eq!( parsed_prereq.1, (Some("LING 229, LING 228;"), Some("MATH 883"), None) ); } }