CLI tool to write off-the-cuff calendar availability.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350//! 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>//! 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::<Vec<_>>() 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<Vec<DayExpr>, 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<String> = 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<Rich<'src, char>>> + 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<Rich<'src, char>>> + 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..=59fn time<'src>() -> impl Parser<'src, &'src str, RawTime, extra::Err<Rich<'src, char>>> + 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 "<time> - <time>".// TODO: Accept * for all day?// TODO: Reject slots that span past midnight e.g. 11pm-1am, 11-1am (especially)?fn slot<'src>() -> impl Parser<'src, &'src str, Slot, extra::Err<Rich<'src, char>>> + Clone { choice(( just("all-day").to(Slot::AllDay), time() .then_ignore(choice((just("-").padded(), just("–").padded()))) .then(time()) .map(|(s, e)| { let (ns, ne) = infer_pair(s, e); Slot::Range(ns, ne) }), ))}
fn infer_pair(mut a: RawTime, mut b: RawTime) -> (NaiveTime, NaiveTime) { fn flip(m: Meridiem) -> Meridiem { match m { Meridiem::Am => Meridiem::Pm, Meridiem::Pm => Meridiem::Am, } } match (a.meridiem, b.meridiem) { // Both annotated, or neither — read each independently. (Some(_), Some(_)) | (None, None) => {} // Only end annotated. Start inherits. (None, Some(m)) if (1..=12).contains(&a.hour) => { a.meridiem = Some(if a.hour <= b.hour { m } else { flip(m) }); } // Only start annotated. End inherits. (Some(m), None) if (1..=12).contains(&b.hour) => { b.meridiem = Some(if b.hour >= a.hour { m } else { flip(m) }); } // Mixed cases where the unmarked end is outside 1..=12: leave as 24h. _ => {} } (a.to_naive(), b.to_naive())}
// One day expression: dow ('-' dow)? slot (',' slot)*fn day_expr<'src>() -> impl Parser<'src, &'src str, DayExpr, extra::Err<Rich<'src, char>>> + Clone { dow() .then(just("-").padded().ignore_then(dow()).or_not()) .padded() .then( slot() .separated_by(just(",").padded()) .at_least(1) .collect::<Vec<Slot>>(), ) .map(|((start, end), slots)| DayExpr { start, end, slots }) .labelled("day")}
// Top level: day_expr ((';' | '\n') day_expr)* end-of-inputfn input_parser<'src>() -> impl Parser<'src, &'src str, Vec<DayExpr>, extra::Err<Rich<'src, char>>>{ day_expr() .separated_by(one_of(";\n").padded()) .at_least(1) .collect::<Vec<DayExpr>>() // match the end and ignore it - anything else rejects .then_ignore(end())}
#[cfg(test)]mod tests { use super::*;
/// Build a NaiveTime from (hour, minute) for terse test assertions. fn nt(h: u32, m: u32) -> NaiveTime { NaiveTime::from_hms_opt(h, m, 0).expect("test fixture: valid h/m") }
#[test] fn parse_dow_parses_each_day() { for (s, expected) in [ ("mon", Dow::Mon), ("tue", Dow::Tue), ("wed", Dow::Wed), ("thu", Dow::Thu), ("fri", Dow::Fri), ("sat", Dow::Sat), ("sun", Dow::Sun), ] { let got = dow() .parse(s) .into_result() .unwrap_or_else(|e| panic!("{s}: {e:?}")); assert_eq!(got, expected); } }
#[test] fn parse_meridiem_short_and_long() { for (s, expected) in [ ("a", Meridiem::Am), ("am", Meridiem::Am), ("p", Meridiem::Pm), ("pm", Meridiem::Pm), ] { let got = meridiem().parse(s).into_result().unwrap(); assert_eq!(got, expected); } }
#[test] fn parse_time_basic_forms() { let cases = [ ("9", nt(9, 0)), ("9:30", nt(9, 30)), ("9a", nt(9, 0)), ("11pm", nt(23, 0)), ("9:30am", nt(9, 30)), ("12am", nt(0, 0)), ("12pm", nt(12, 0)), ]; for (s, expected) in cases { let got = time() .parse(s) .into_result() .unwrap_or_else(|e| panic!("{s}: {e:?}")); println!("{:?} = {:?} v {:?}", got, got.to_naive(), expected); assert_eq!(got.to_naive(), expected, "{s}"); } }
#[test] fn parse_time_rejects_out_of_range() { // 13 with meridiem is invalid. assert!(time().parse("13pm").into_result().is_err()); // minute >= 60 is invalid. assert!(time().parse("9:99").into_result().is_err()); }
#[test] fn parse_slot_all_day() { assert_eq!(slot().parse("all-day").into_result().unwrap(), Slot::AllDay); }
#[test] fn parse_slot_range() { assert_eq!( slot().parse("9-11a").into_result().unwrap(), Slot::Range(nt(9, 0), nt(11, 0)), ); assert_eq!( slot().parse("9:30-11a").into_result().unwrap(), Slot::Range(nt(9, 30), nt(11, 0)), ); assert_eq!( slot().parse("8:30-11:45").into_result().unwrap(), Slot::Range(nt(8, 30), nt(11, 45)), ); }
#[test] fn parse_slot_range_carry_meridiem_from_latter() { assert_eq!( slot().parse("2-3p").into_result().unwrap(), Slot::Range(nt(14, 0), nt(15, 0)), ); assert_eq!( slot().parse("9-11pm").into_result().unwrap(), Slot::Range(nt(21, 0), nt(23, 0)), ); }
#[test] fn parse_slot_range_dont_carry_meridiem_from_former() { assert_eq!( slot().parse("11am-1").into_result().unwrap(), Slot::Range(nt(11, 0), nt(13, 0)), ); }
#[test] fn parse_around_noon_meridiem() { assert_eq!( slot().parse("11-1pm").into_result().unwrap(), Slot::Range(nt(11, 0), nt(13, 0)), ); }
#[test] fn parse_day_expr_simple() { let out = day_expr().parse("mon 9-11a").into_result().unwrap(); assert_eq!(out.start, Dow::Mon); assert!(out.end.is_none()); assert_eq!(out.slots.len(), 1); }
#[test] fn parse_day_expr_range_and_multi_slot() { let out = day_expr() .parse("wed-fri 9-11a, 2-4p") .into_result() .unwrap(); assert_eq!(out.start, Dow::Wed); assert_eq!(out.end, Some(Dow::Fri)); assert_eq!(out.slots.len(), 2); }
#[test] fn parse_multiple_day_exprs() { let out = parse("mon 9-11a; tue 1-5p").unwrap(); assert_eq!(out.len(), 2);
let out = parse("mon 9-11a;\n tue 1-5p").unwrap(); assert_eq!(out.len(), 2); }
#[test] fn parse_rejects_trailing_garbage() { assert!(parse("mon 9-11a !!!").is_err()); }
#[test] fn parse_rejects_bogus_dow() { assert!(parse("xyz 9-11a").is_err()); }
#[test] fn parse_rejects_dangling_dash_after_dow() { assert!(parse("mon- 9-11a").is_err()); assert!(parse("mon-").is_err()); }}