//! 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 "