diff --git a/src/files.rs b/src/files.rs index 446265d..aed2955 100644 --- a/src/files.rs +++ b/src/files.rs @@ -19,9 +19,16 @@ pub struct Files { #[derive(Debug, thiserror::Error)] pub enum Error { #[error("{0}")] - IoError(#[from] io::Error), + Io(#[from] io::Error), #[error("{0}")] - ParseError(#[from] parse::Error), + Parse(#[from] parse::Error), + #[error("{file1} has time zone {tz1} but {file2} has time zone {tz2}")] + TzConflict { + file1: PathBuf, + tz1: Tz, + file2: PathBuf, + tz2: Tz, + }, } pub type Result = result::Result; @@ -33,30 +40,56 @@ impl Files { timezone: None, }; - new.load_file(path.to_owned())?; + new.load_file(path)?; new.determine_timezone()?; Ok(new) } - fn load_file(&mut self, path: PathBuf) -> Result<()> { + fn load_file(&mut self, path: &Path) -> Result<()> { let canon_path = path.canonicalize()?; if self.files.contains_key(&canon_path) { // We've already loaded this exact file. return Ok(()); } - let content = fs::read_to_string(&path)?; + let content = fs::read_to_string(path)?; let file = parse::parse(path, &content)?; + let includes = file.includes.clone(); + self.files.insert(canon_path, file); - // TODO Also load all included files + for include in includes { + self.load_file(&include)?; + } Ok(()) } fn determine_timezone(&mut self) -> Result<()> { - // TODO Implement once files can specify time zones + let mut found: Option<(PathBuf, Tz)> = None; + + for file in self.files.values() { + if let Some(file_tz) = file.timezone { + if let Some((found_name, found_tz)) = &found { + if *found_tz != file_tz { + return Err(Error::TzConflict { + file1: found_name.clone(), + tz1: *found_tz, + file2: file.name.clone(), + tz2: file_tz, + }); + } + } else { + found = Some((file.name.clone(), file_tz)); + } + } + } + + if let Some((_, tz)) = found { + self.timezone = Some(tz); + } + Ok(()) } } diff --git a/src/files/grammar.pest b/src/files/grammar.pest index 1a9aff7..f44a486 100644 --- a/src/files/grammar.pest +++ b/src/files/grammar.pest @@ -3,6 +3,9 @@ WHITESPACE = _{ !eol ~ WHITE_SPACE } rest_some = { (!eol ~ ANY)+ } rest_any = { (!eol ~ ANY)* } +include = { "INCLUDE" ~ WHITESPACE ~ rest_some ~ eol } +timezone = { "TIMEZONE" ~ WHITESPACE ~ rest_some ~ eol } + number = @{ ASCII_DIGIT{1,9} } // Fits into an i32 title = { WHITESPACE ~ rest_some ~ eol } @@ -136,6 +139,6 @@ birthday = { } empty_line = _{ WHITESPACE* ~ NEWLINE } -command = { task | note | birthday } +command = { include | timezone | task | note | birthday } file = ${ SOI ~ (empty_line* ~ command)* ~ empty_line* ~ WHITESPACE* ~ EOI } diff --git a/src/files/parse.rs b/src/files/parse.rs index d523358..dbfe574 100644 --- a/src/files/parse.rs +++ b/src/files/parse.rs @@ -1,7 +1,8 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::result; use chrono::NaiveDate; +use chrono_tz::Tz; use pest::error::ErrorVariant; use pest::iterators::Pair; use pest::prec_climber::{Assoc, Operator, PrecClimber}; @@ -32,6 +33,22 @@ fn fail, T>(span: Span, message: S) -> Result { Err(error(span, message)) } +fn parse_include(p: Pair) -> String { + assert_eq!(p.as_rule(), Rule::include); + p.into_inner().next().unwrap().as_str().to_string() +} + +fn parse_timezone(p: Pair) -> Result { + assert_eq!(p.as_rule(), Rule::timezone); + let span = p.as_span(); + p.into_inner() + .next() + .unwrap() + .as_str() + .parse() + .map_err(|_| error(span, "invalid timezone")) +} + fn parse_number(p: Pair) -> i32 { assert_eq!(p.as_rule(), Rule::number); p.as_str().parse().unwrap() @@ -691,34 +708,58 @@ fn parse_birthday(p: Pair) -> Result { Ok(Birthday { title, when, desc }) } -fn parse_command(p: Pair) -> Result { +fn parse_command(p: Pair, file: &mut File) -> Result<()> { assert_eq!(p.as_rule(), Rule::command); let p = p.into_inner().next().unwrap(); match p.as_rule() { - Rule::task => parse_task(p).map(Command::Task), - Rule::note => parse_note(p).map(Command::Note), - Rule::birthday => parse_birthday(p).map(Command::Birthday), + Rule::include => { + // Since we've successfully opened the file, its name can't be the + // root directory or empty string and must thus have a parent. + let parent = file.name.parent().unwrap(); + file.includes.push(parent.join(parse_include(p))); + } + Rule::timezone => match file.timezone { + None => file.timezone = Some(parse_timezone(p)?), + Some(_) => fail(p.as_span(), "cannot set timezone multiple times")?, + }, + Rule::task => file.commands.push(Command::Task(parse_task(p)?)), + Rule::note => file.commands.push(Command::Note(parse_note(p)?)), + Rule::birthday => file.commands.push(Command::Birthday(parse_birthday(p)?)), _ => unreachable!(), } + + Ok(()) } -pub fn parse(path: PathBuf, input: &str) -> Result { - let pathstr = path.to_string_lossy(); - let mut pairs = TodayfileParser::parse(Rule::file, input)?; - let file = pairs.next().unwrap(); - let commands = file - .into_inner() - // For some reason, the EOI in `file` always gets captured - .take_while(|p| p.as_rule() == Rule::command) - .map(parse_command) - .collect::>() - .map_err(|e| e.with_path(&pathstr))?; +pub fn parse_file(p: Pair, name: PathBuf) -> Result { + assert_eq!(p.as_rule(), Rule::file); - Ok(File { - name: path, + let mut file = File { + name, includes: vec![], timezone: None, - commands, - }) + commands: vec![], + }; + + for p in p.into_inner() { + // For some reason, the EOI in `file` always gets captured + if p.as_rule() == Rule::EOI { + break; + } + + parse_command(p, &mut file)?; + } + + Ok(file) +} + +pub fn parse(path: &Path, input: &str) -> Result { + let pathstr = path.to_string_lossy(); + + let mut pairs = TodayfileParser::parse(Rule::file, input).map_err(|e| e.with_path(&pathstr))?; + let file_pair = pairs.next().unwrap(); + assert_eq!(pairs.next(), None); + + parse_file(file_pair, path.to_owned()).map_err(|e| e.with_path(&pathstr)) }