//! DSL parser using chumsky. //! //! Grammar (BNF): //! input := day_expr ((';' | '\n') day_expr)* //! day_expr := day_spec slot (',' slot)* //! day_spec := dow ('-' dow)? //! slot := 'all-day' | time '-' time //! time := H[:MM] (am|pm|a|p)? //! dow := mon|tue|wed|thu|fri|sat|sun //! //! Reference //! just("text") parse a literal, value is &str //! just('c').to(value) parse and replace value //! choice((p1, p2, p3)) try each in order //! p.then(q) p then q, value is (P, Q) //! p.then_ignore(q) p then q, value is P (q discarded) //! p.ignore_then(q) p then q, value is Q (p discarded) //! p.or(q) p or q //! p.or_not() optional, value is Option

//! p.repeated() zero or more, value is iterator-like //! p.separated_by(sep) p (sep p)* //! p.at_least(n) minimum count for repeated/separated_by //! p.collect::>() finalize a repeated/separated_by into a Vec //! p.padded() eat whitespace around p //! p.map(|v| ...) transform the value //! p.try_map(|v, span| Ok(...) | Err(Rich::custom(span, "..."))) //! p.labelled("name") improve error messages //! end() match end of input use crate::model::{DayExpr, Dow, Hour12, Hour24, Meridiem, Minute, RawTime, Slot}; use chrono::NaiveTime; use chumsky::prelude::*; // Parses the whole input or returns error. pub fn parse(input: &str) -> Result, String> { match input_parser().parse(input).into_result() { Ok(exprs) => Ok(exprs), Err(errs) => { // TODO: render with `ariadne` for pretty caret-pointing // diagnostics. let msgs: Vec = errs .iter() .map(|e| format!("parse error at {:?}: {}", e.span(), e)) .collect(); Err(msgs.join("\n\n")) } } } // Parse one of "mon", "tue", "wed", "thu", "fri", "sat", "sun" into Dow. // TODO: accept other common strings, e.g. "m", "tues", "th" fn dow<'src>() -> impl Parser<'src, &'src str, Dow, extra::Err>> + Clone { choice(( just("mon").to(Dow::Mon), just("tue").to(Dow::Tue), just("wed").to(Dow::Wed), just("thu").to(Dow::Thu), just("fri").to(Dow::Fri), just("sat").to(Dow::Sat), just("sun").to(Dow::Sun), )) .labelled("day of week") } // Parse "am" / "a" / "pm" / "p" into Meridiem. fn meridiem<'src>() -> impl Parser<'src, &'src str, Meridiem, extra::Err>> + Clone { // IMPORTANT: order matters in `choice`. "am" must come before "a", otherwise // the parser greedily consumes "a" and leaves "m" dangling. Same for "pm"/"p". choice(( just("am").to(Meridiem::Am), just("a").to(Meridiem::Am), just("pm").to(Meridiem::Pm), just("p").to(Meridiem::Pm), )) .labelled("meridiem") } // Parse "9", "9:30", "9a", "9:30am", "11pm", "13" into RawTime. // Validation rules (return Err(Rich::custom(span, "..."))): // - hour: if meridiem.is_some(), 1..=12; else 0..=23 // - minute (default 0): 0..=59 fn time<'src>() -> impl Parser<'src, &'src str, RawTime, extra::Err>> + Clone { text::int(10) .then(just(':').ignore_then(text::int(10)).or_not()) .then(meridiem().or_not()) .try_map(|((h_str, m_str), meridiem), span| { let h_raw: u8 = h_str .parse() .map_err(|e: std::num::ParseIntError| Rich::custom(span, e.to_string()))?; let m_raw: u8 = match m_str { Some(s) => s .parse() .map_err(|e: std::num::ParseIntError| Rich::custom(span, e.to_string()))?, None => 0, }; // Validate minutes let minute = Minute::new(m_raw).ok_or_else(|| Rich::custom(span, "minute must be 0..=59"))?; // Validate hours as given, relative to the meridiem if provided let h24: u8 = match meridiem { None => Hour24::new(h_raw) .ok_or_else(|| Rich::custom(span, "hour must be 0..=23"))? .get(), Some(_) => Hour12::new(h_raw) .ok_or_else(|| Rich::custom(span, "hour must be 1..=12 with am/pm"))? .get(), }; // Pass on time as received with meridiem. Ok(RawTime { hour: h24, minute: minute.get(), meridiem, }) }) .labelled("time") } // A slot is either "all-day" or a time range "