use std::collections::BTreeSet;
pub use pest_consume::{match_nodes, Error, Parser};
use TodoBodyToken::*;
#[derive(Eq, PartialEq, Debug, Clone)]
enum TodoBodyToken {
Word(String),
Context(String),
Project(String),
ThresholdDate(time::Date),
DueDate(time::Date),
Hidden(bool),
}
#[derive(Eq, PartialEq, Debug)]
pub struct Todo {
pub is_completed: bool,
pub priority: Option<char>,
pub creation_date: Option<time::Date>,
pub completion_date: Option<time::Date>,
tokens: Vec<TodoBodyToken>,
pub threshold_date: Option<time::Date>,
pub due_date: Option<time::Date>,
pub contexts: BTreeSet<String>,
pub projects: BTreeSet<String>,
pub is_hidden: bool,
}
#[allow(dead_code)]
impl Todo {
pub fn has_context(&self, context: &str) -> bool {
if !context.starts_with('@') {
let c = "@".to_string() + context;
return self.has_context(&c);
}
self.contexts.contains(context)
}
pub fn has_project(&self, project: &str) -> bool {
if !project.starts_with('+') {
let c = "+".to_string() + project;
return self.has_project(&c);
}
self.projects.contains(project)
}
pub fn canonical_context(&self) -> Option<String> {
self.contexts.first().map(|s| s.to_owned())
}
pub fn canonical_project(&self) -> Option<String> {
self.projects.first().map(|s| s.to_owned())
}
pub fn parse(input: &str) -> std::result::Result<Todo, TodoParserError> {
let input = input.trim();
if input.is_empty() {
return Err(TodoParserError::EmptyInput);
}
let nodes = TodoParser::parse(Rule::todo, input)?;
let node = nodes.single()?;
let todo = TodoParser::todo(node)?;
Ok(todo)
}
}
#[derive(Debug)]
struct TodoBuilder {
is_completed: bool,
priority: Option<char>,
creation_date: Option<time::Date>,
completion_date: Option<time::Date>,
tokens: Vec<TodoBodyToken>,
threshold_date: Option<time::Date>,
due_date: Option<time::Date>,
contexts: BTreeSet<String>,
projects: BTreeSet<String>,
is_hidden: bool,
}
impl TodoBuilder {
pub fn default() -> Self {
TodoBuilder {
is_completed: false,
priority: None,
creation_date: None,
completion_date: None,
tokens: Vec::new(),
threshold_date: None,
due_date: None,
contexts: BTreeSet::new(),
projects: BTreeSet::new(),
is_hidden: false,
}
}
pub fn completed(&mut self, completed: bool) -> &mut Self {
self.is_completed = completed;
self
}
pub fn priority(&mut self, priority: char) -> &mut Self {
self.priority = Some(priority);
self
}
pub fn creation_date(&mut self, creation_date: time::Date) -> &mut Self {
self.creation_date = Some(creation_date);
self
}
pub fn completion_date(&mut self, completion_date: time::Date) -> &mut Self {
self.is_completed = true;
self.completion_date = Some(completion_date);
self
}
pub fn tokens(&mut self, tokens: Vec<TodoBodyToken>) -> &mut Self {
for t in tokens {
&self.push_token(t);
}
self
}
pub fn push_token(&mut self, tkn: TodoBodyToken) {
self.tokens.push(tkn.clone());
match tkn {
TodoBodyToken::Context(s) => {
self.contexts.insert(s); }
TodoBodyToken::Project(s) => {
self.projects.insert(s); }
TodoBodyToken::ThresholdDate(td) => {
self.threshold_date = Some(td); }
TodoBodyToken::DueDate(d) => {
self.due_date = Some(d); }
TodoBodyToken::Hidden(b) => {
self.is_hidden = b;
}
TodoBodyToken::Word(_) => {}
}
}
pub fn build(&mut self) -> Todo {
let tokens = std::mem::replace(&mut self.tokens, vec![]);
let contexts = std::mem::replace(&mut self.contexts, BTreeSet::new());
let projects = std::mem::replace(&mut self.projects, BTreeSet::new());
Todo {
is_completed: self.is_completed,
priority: self.priority,
creation_date: self.creation_date,
completion_date: self.completion_date,
tokens,
threshold_date: self.threshold_date,
due_date: self.due_date,
contexts,
projects,
is_hidden: self.is_hidden,
}
}
}
#[derive(Debug)]
struct TodoBodyTokensBuilder {
tokens: Vec<TodoBodyToken>,
}
impl TodoBodyTokensBuilder {
fn default() -> TodoBodyTokensBuilder {
TodoBodyTokensBuilder { tokens: Vec::new() }
}
fn word(mut self, s: &str) -> TodoBodyTokensBuilder {
self.tokens.push(Word(s.to_string()));
self
}
fn project(mut self, s: &str) -> TodoBodyTokensBuilder {
self.tokens.push(Project(s.to_string()));
self
}
fn context(mut self, s: &str) -> TodoBodyTokensBuilder {
self.tokens.push(Context(s.to_string()));
self
}
fn threshold_date(mut self, d: time::Date) -> TodoBodyTokensBuilder {
self.tokens.push(ThresholdDate(d));
self
}
fn due_date(mut self, d: time::Date) -> TodoBodyTokensBuilder {
self.tokens.push(DueDate(d));
self
}
fn build(self) -> Vec<TodoBodyToken> {
self.tokens
}
}
#[derive(Parser)]
#[grammar = "./todotxt.pest"]
struct TodoParser;
type PestResult<T> = std::result::Result<T, pest_consume::Error<Rule>>;
type Node<'i> = pest_consume::Node<'i, Rule, ()>;
fn parse_date(node: Node, input: &str) -> PestResult<time::Date> {
time::Date::parse(input, "%F").map_err(|e| node.error(e))
}
#[pest_consume::parser]
impl TodoParser {
fn completion(_input: Node) -> PestResult<bool> {
Ok(true)
}
fn priority(input: Node) -> PestResult<char> {
let mut chars = input.as_str().chars();
chars.next();
let pri = chars.next().unwrap().to_uppercase().next().unwrap();
Ok(pri)
}
fn date(input: Node) -> PestResult<time::Date> {
let s = input.as_str();
Ok(parse_date(input, s)?)
}
fn creation_date(input: Node) -> PestResult<time::Date> {
TodoParser::date(input.children().single()?)
}
fn completion_date(input: Node) -> PestResult<time::Date> {
TodoParser::date(input.children().single()?)
}
fn context(input: Node) -> PestResult<TodoBodyToken> {
Ok(Context(input.as_str().to_string()))
}
fn project(input: Node) -> PestResult<TodoBodyToken> {
Ok(Project(input.as_str().to_string()))
}
fn threshold_date(input: Node) -> PestResult<TodoBodyToken> {
let mut chars = input.as_str().chars();
chars.next(); chars.next(); let d = parse_date(input, chars.as_str())?;
Ok(ThresholdDate(d))
}
fn due_date(input: Node) -> PestResult<TodoBodyToken> {
let mut chars = input.as_str().chars();
chars.next(); chars.next(); chars.next(); chars.next(); let d = parse_date(input, chars.as_str())?;
Ok(DueDate(d))
}
fn hidden(input: Node) -> PestResult<TodoBodyToken> {
let mut chars = input.as_str().chars();
chars.next(); chars.next(); let v = chars.next().unwrap(); let b = if v == '1' {
true
} else if v == '0' {
false
} else {
unreachable!();
};
Ok(Hidden(b))
}
fn word(input: Node) -> PestResult<TodoBodyToken> {
Ok(Word(input.as_str().to_string()))
}
fn token(input: Node) -> PestResult<TodoBodyToken> {
let t = match_nodes!(input.into_children();
[context(t)] => t,
[project(t)] => t,
[threshold_date(t)] => t,
[due_date(t)] => t,
[hidden(t)] => t,
[word(t)] => t,
);
Ok(t)
}
fn todo(node: Node) -> PestResult<Todo> {
let mut tb = TodoBuilder::default();
match_nodes!(node.into_children();
[completion(c), priority(p), creation_date(crd), completion_date(cod), token(tkns)..]
=> {
tb.completed(c);
tb.priority(p);
tb.creation_date(crd);
tb.completion_date(cod);
tb.tokens(tkns.collect());
},
[priority(p), creation_date(crd), completion_date(cod), token(tkns)..]
=> {
tb.priority(p);
tb.creation_date(crd);
tb.tokens(tkns.collect());
log::debug!("ignoring completion date {:?} from incomplete todo {:?}", cod, tb)
},
[completion(c), creation_date(crd), completion_date(cod), token(tkns)..]
=> {
tb.completed(c);
tb.creation_date(crd);
tb.completion_date(cod);
tb.tokens(tkns.collect());
},
[completion(c), priority(p), completion_date(cod), token(tkns)..]
=> {
unreachable!()
},
[completion(c), priority(p), creation_date(crd), token(tkns)..]
=> {
tb.completed(c);
tb.priority(p);
tb.creation_date(crd);
tb.tokens(tkns.collect());
},
[creation_date(crd), completion_date(cod), token(tkns)..]
=> {
tb.creation_date(crd);
tb.tokens(tkns.collect());
log::debug!("ignoring completion date {:?} from incomplete todo {:?}", cod, tb)
},
[priority(p), completion_date(cod), token(tkns)..]
=> {
unreachable!()
},
[priority(p), creation_date(crd), token(tkns)..]
=> {
tb.priority(p);
tb.creation_date(crd);
tb.tokens(tkns.collect());
},
[completion(c), completion_date(cod), token(tkns)..]
=> {
unreachable!()
},
[completion(c), creation_date(crd), token(tkns)..]
=> {
tb.completed(c);
tb.creation_date(crd);
tb.tokens(tkns.collect());
},
[completion(c), priority(p), token(tkns)..]
=> {
tb.completed(c);
tb.priority(p);
tb.tokens(tkns.collect());
},
[completion(c), token(tkns)..]
=> {
tb.completed(c);
tb.tokens(tkns.collect());
},
[priority(p), token(tkns)..]
=> {
tb.priority(p);
tb.tokens(tkns.collect());
},
[completion_date(cod), token(tkns)..]
=> {
unreachable!()
},
[creation_date(crd), token(tkns)..]
=> {
tb.creation_date(crd);
tb.tokens(tkns.collect());
},
[token(tkns)..]
=> {
tb.tokens(tkns.collect());
},
);
Ok(tb.build())
}
}
#[derive(Debug)]
pub enum TodoParserError {
EmptyInput,
ParseError(pest_consume::Error<Rule>),
}
impl std::fmt::Display for TodoParserError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:?}", self)
}
}
impl std::error::Error for TodoParserError {}
impl std::convert::From<pest_consume::Error<Rule>> for TodoParserError {
fn from(e: pest_consume::Error<Rule>) -> Self {
TodoParserError::ParseError(e)
}
}
#[cfg(test)]
mod test {
use pretty_assertions::assert_eq;
use crate::utils;
use super::*;
fn parse_node<T>(
root_rule: Rule,
visitor: fn(Node) -> PestResult<T>,
input: &str,
) -> PestResult<T> {
let nodes = TodoParser::parse(root_rule, input)?;
let node = nodes.single()?;
visitor(node)
}
fn parse_token(input: &str) -> PestResult<TodoBodyToken> {
parse_node(Rule::token, TodoParser::token, input)
}
#[test]
fn parse_intermediate_parsers_work_ok() {
utils::test::init();
assert_eq!(
Ok(true),
parse_node(Rule::completion, TodoParser::completion, "x")
);
assert_eq!(
Ok('D'),
parse_node(Rule::priority, TodoParser::priority, "(D)")
);
assert_eq!(
Ok(time::date!(2020 - 12 - 13)),
parse_node(Rule::date, TodoParser::date, "2020-12-13")
);
assert_eq!(
Ok(time::date!(2020 - 12 - 13)),
parse_node(Rule::creation_date, TodoParser::creation_date, "2020-12-13")
);
assert_eq!(
Ok(time::date!(2020 - 02 - 29)),
parse_node(
Rule::completion_date,
TodoParser::completion_date,
"2020-02-29",
)
);
assert_eq!(
Ok(TodoBodyToken::Context("@foo".to_string())),
parse_node(Rule::context, TodoParser::context, "@foo")
);
assert_eq!(
Ok(TodoBodyToken::Project("+foo".to_string())),
parse_node(Rule::project, TodoParser::project, "+foo")
);
assert_eq!(
Ok(TodoBodyToken::ThresholdDate(time::date!(2020 - 11 - 14))),
parse_node(
Rule::threshold_date,
TodoParser::threshold_date,
"t:2020-11-14",
)
);
assert_eq!(
Ok(TodoBodyToken::DueDate(time::date!(2020 - 04 - 04))),
parse_node(Rule::due_date, TodoParser::due_date, "due:2020-04-04")
);
assert_eq!(
Ok(TodoBodyToken::Hidden(true)),
parse_node(Rule::hidden, TodoParser::hidden, "h:1")
);
assert_eq!(
Ok(TodoBodyToken::Hidden(false)),
parse_node(Rule::hidden, TodoParser::hidden, "h:0")
);
assert_eq!(
Ok(TodoBodyToken::Word("hi".to_string())),
parse_node(Rule::word, TodoParser::word, "hi")
);
assert_eq!(
Ok(TodoBodyToken::Context("@foo".to_string())),
parse_token("@foo")
);
assert_eq!(
Ok(TodoBodyToken::Project("+foo".to_string())),
parse_token("+foo")
);
assert_eq!(
Ok(TodoBodyToken::ThresholdDate(time::date!(2020 - 11 - 14))),
parse_token("t:2020-11-14")
);
assert_eq!(
Ok(TodoBodyToken::DueDate(time::date!(2020 - 04 - 04))),
parse_token("due:2020-04-04")
);
assert_eq!(Ok(TodoBodyToken::Hidden(true)), parse_token("h:1"));
assert_eq!(Ok(TodoBodyToken::Hidden(false)), parse_token("h:0"));
assert_eq!(Ok(TodoBodyToken::Word("hi".to_string())), parse_token("hi"));
}
#[test]
fn parsing_a_todo() {
utils::test::init();
let got = Todo::parse(
"x (A) 2020-10-10 2020-12-29 write +tudor Todo parser @pc t:2020-12-25 due:2020-12-30)",
)
.unwrap();
let exp = TodoBuilder::default()
.completed(true)
.priority('A')
.creation_date(time::date!(2020 - 10 - 10))
.completion_date(time::date!(2020 - 12 - 29))
.tokens(
TodoBodyTokensBuilder::default()
.word("write")
.project("+tudor")
.word("Todo")
.word("parser")
.context("@pc")
.threshold_date(time::date!(2020 - 12 - 25))
.due_date(time::date!(2020 - 12 - 30))
.build(),
)
.build();
assert_eq!(got, exp);
}
#[test]
fn parsing_a_todo_with_ws() {
utils::test::init();
let got = Todo::parse(
" x (B) 2020-10-10 2020-12-29 parse todos with whitespace +tudor @pc t:2020-12-25 due:2020-12-31) ",
).unwrap();
let exp = TodoBuilder::default()
.completed(true)
.priority('B')
.creation_date(time::date!(2020 - 10 - 10))
.completion_date(time::date!(2020 - 12 - 29))
.tokens(
TodoBodyTokensBuilder::default()
.word("parse")
.word("todos")
.word("with")
.word("whitespace")
.project("+tudor")
.context("@pc")
.threshold_date(time::date!(2020 - 12 - 25))
.due_date(time::date!(2020 - 12 - 31))
.build(),
)
.build();
assert_eq!(got, exp);
}
#[test]
fn parsing_a_todo_with_body_only() {
utils::test::init();
let got = Todo::parse("try out pest @pc").unwrap();
let exp = TodoBuilder::default()
.tokens(
TodoBodyTokensBuilder::default()
.word("try")
.word("out")
.word("pest")
.context("@pc")
.build(),
)
.build();
assert_eq!(got, exp);
}
#[test]
fn parsing_some_other_todos() {
utils::test::init();
assert!(Todo::parse("(A)").is_ok());
assert!(Todo::parse("(S) 1029-12-19 1029-12-20 try out pest @pc").is_ok());
assert!(Todo::parse("(S) 1029-12-19 try out pest @pc").is_ok());
assert!(Todo::parse("(S) try out pest @pc").is_ok());
assert!(Todo::parse("1029-12-19 token token").is_ok());
assert!(Todo::parse("1029-12-19").is_ok());
assert!(Todo::parse("1039-12-12 1999-12-12 try out pest @pc").is_ok());
assert!(Todo::parse("1039-12-12 try out pest @pc").is_ok());
assert!(Todo::parse("x (A) 1029-12-20 token token").is_ok());
assert!(Todo::parse("x (S) 1029-12-19 1029-12-19 try out pest @pc").is_ok());
assert!(Todo::parse("x (S) 1029-12-19 try out pest @pc").is_ok());
assert!(Todo::parse("x (S) try out pest @pc").is_ok());
assert!(Todo::parse("x (S)").is_ok());
assert!(Todo::parse("x 1029-12-19 1029-12-20 token token").is_ok());
assert!(Todo::parse("x 1039-12-12 try out pest @pc").is_ok());
assert!(Todo::parse("x try out pest @pc").is_ok());
assert!(Todo::parse("x").is_ok());
}
}