1use crate::task::TaskPriority;
2use chrono::NaiveDateTime;
3use color_eyre::eyre::{bail, Context, Result};
4use nom::{
5 branch::alt,
6 bytes::complete::{tag, take_while1},
7 character::{complete::char, is_newline, is_space},
8 combinator::{all_consuming, map},
9 sequence::{preceded, separated_pair},
10 IResult,
11};
12use std::str::FromStr;
13use thiserror::Error;
14
15#[derive(Debug, PartialEq, Eq)]
18enum ExpressionPrototype<'a> {
19 Description(&'a str),
20 Project(&'a str),
21 Tag(&'a str),
22 Metadata { key: &'a str, value: &'a str },
23 Priority(&'a str),
24 Duedate(&'a str),
25}
26
27#[derive(Debug, PartialEq, Eq)]
29pub enum Expression {
30 Description(String),
32 Project(String),
34 Tag(String),
36 Metadata {
38 key: String,
40 value: String,
42 },
43 Priority(TaskPriority),
45 Duedate(NaiveDateTime),
47}
48
49impl Expression {
50 fn from_prototype(prototype: &ExpressionPrototype) -> Result<Self> {
51 Ok(match prototype {
52 ExpressionPrototype::Description(text) => Expression::Description(String::from(*text)),
53 ExpressionPrototype::Project(text) => Expression::Project(String::from(*text)),
54 ExpressionPrototype::Tag(text) => Expression::Tag(String::from(*text)),
55 ExpressionPrototype::Metadata { key, value } => Expression::Metadata {
56 key: String::from(*key),
57 value: String::from(*value),
58 },
59 ExpressionPrototype::Priority(text) => {
60 let mut prio_string = text.to_string().to_lowercase();
61 prio_string = prio_string[0..1].to_uppercase() + &prio_string[1..];
62 Expression::Priority(
63 TaskPriority::from_str(&prio_string)
64 .with_context(|| "invalid priority specified in descriptor")?,
65 )
66 }
67 ExpressionPrototype::Duedate(text) => Expression::Duedate(
68 NaiveDateTime::from_str(text)
69 .with_context(|| "invalid date time format for duedate in descriptor")?,
70 ),
71 })
72 }
73}
74
75fn nonws_char(c: char) -> bool {
76 !is_space(c as u8) && !is_newline(c as u8)
77}
78
79fn allowed_meta_character(c: char) -> bool {
80 nonws_char(c) && c != '='
81}
82
83fn word(input: &str) -> IResult<&str, &str> {
84 take_while1(nonws_char)(input)
85}
86
87fn meta_word(input: &str) -> IResult<&str, &str> {
88 take_while1(allowed_meta_character)(input)
89}
90
91fn metadata_pair(input: &str) -> IResult<&str, (&str, &str)> {
92 separated_pair(meta_word, char('='), meta_word)(input)
93}
94
95fn hashtag(input: &str) -> IResult<&str, &str> {
96 preceded(char('#'), word)(input)
97}
98
99fn hashtag2(input: &str) -> IResult<&str, &str> {
100 preceded(alt((tag("tag:"), tag("TAG:"))), word)(input)
101}
102
103fn project(input: &str) -> IResult<&str, &str> {
104 preceded(char('@'), word)(input)
105}
106
107fn project2(input: &str) -> IResult<&str, &str> {
108 preceded(
109 alt((tag("prj:"), tag("proj:"), tag("PRJ:"), tag("PROJ:"))),
110 word,
111 )(input)
112}
113
114fn metadata(input: &str) -> IResult<&str, (&str, &str)> {
115 preceded(char('%'), metadata_pair)(input)
116}
117
118fn metadata2(input: &str) -> IResult<&str, (&str, &str)> {
119 preceded(alt((tag("META:"), tag("meta:"))), metadata_pair)(input)
120}
121
122fn priority(input: &str) -> IResult<&str, &str> {
123 preceded(alt((tag("prio:"), tag("PRIO:"))), word)(input)
124}
125
126fn due_date(input: &str) -> IResult<&str, &str> {
127 preceded(
128 alt((tag("due:"), tag("DUE:"), tag("duedate:"), tag("DUEDATE:"))),
129 word,
130 )(input)
131}
132
133fn directive(input: &str) -> IResult<&str, ExpressionPrototype> {
134 alt((
135 map(hashtag, ExpressionPrototype::Tag),
136 map(hashtag2, ExpressionPrototype::Tag),
137 map(project, ExpressionPrototype::Project),
138 map(project2, ExpressionPrototype::Project),
139 map(metadata, |(key, value)| ExpressionPrototype::Metadata {
140 key,
141 value,
142 }),
143 map(metadata2, |(key, value)| ExpressionPrototype::Metadata {
144 key,
145 value,
146 }),
147 map(priority, ExpressionPrototype::Priority),
148 map(due_date, ExpressionPrototype::Duedate),
149 ))(input)
150}
151
152fn parse_inline(input: &str) -> IResult<&str, Vec<ExpressionPrototype>> {
153 let mut output = Vec::with_capacity(4);
154 let mut current_input = input;
155
156 while !current_input.is_empty() {
157 let mut found_directive = false;
158 for (current_index, _) in current_input.char_indices() {
159 match directive(¤t_input[current_index..]) {
161 Ok((remaining, parsed)) => {
162 let leading_text = ¤t_input[0..current_index].trim();
164 if !leading_text.is_empty() {
165 output.push(ExpressionPrototype::Description(leading_text));
166 }
167 output.push(parsed);
168
169 current_input = remaining;
170 found_directive = true;
171 break;
172 }
173 Err(nom::Err::Error(_)) => {
174 }
177 Err(e) => {
178 return Err(e);
180 }
181 }
182 }
183
184 if !found_directive {
185 output.push(ExpressionPrototype::Description(current_input.trim()));
187 break;
188 }
189 }
190
191 Ok(("", output))
192}
193
194#[derive(Error, Debug, PartialEq, Eq)]
196pub enum LexiconError {
197 #[error("i got confused by the language")]
199 ParserError(String),
200}
201
202pub fn parse_task(input: String) -> Result<Vec<Expression>> {
204 let parsed = alt((all_consuming(parse_inline),))(&input).map(|(_, results)| results);
205
206 match parsed {
207 Ok(prototype_expressions) => {
208 let mut ready_expressions: Vec<Expression> = vec![];
209 for expression_prototype in prototype_expressions {
210 ready_expressions.push(
211 Expression::from_prototype(&expression_prototype)
212 .with_context(|| "malformed expression in task descriptor")?,
213 );
214 }
215 Ok(ready_expressions)
216 }
217 Err(error) => bail!(LexiconError::ParserError(error.to_string())),
218 }
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224
225 #[test]
226 fn nonws_char_allowed() {
227 assert!(nonws_char('a'))
228 }
229
230 #[test]
231 fn nonws_char_whitespace() {
232 assert!(!nonws_char(' '))
233 }
234
235 #[test]
236 fn allowed_meta_character_hyphen() {
237 assert!(allowed_meta_character('-'))
238 }
239
240 #[test]
241 fn allowed_meta_character_whitespace() {
242 assert!(!allowed_meta_character(' '))
243 }
244
245 #[test]
246 fn word_from_trimmed() {
247 assert_eq!(word("word").unwrap(), ("", "word"));
248 }
249
250 #[test]
251 fn word_from_untrimmed_r() {
252 assert_eq!(word("word ").unwrap(), (" ", "word"));
253 }
254
255 #[test]
256 fn word_from_untrimmed_l() {
257 assert!(word(" word").is_err());
258 }
259
260 #[test]
261 fn metaword_from_allowed() {
262 assert_eq!(meta_word("x-meta-word").unwrap(), ("", "x-meta-word"));
263 }
264
265 #[test]
266 fn metaword_from_whitespace() {
267 assert_eq!(meta_word("x-meta -word").unwrap(), (" -word", "x-meta"));
268 }
269
270 #[test]
271 fn tag_valid() {
272 assert_eq!(hashtag("#fubar").unwrap(), ("", "fubar"));
273 }
274
275 #[test]
276 fn tag2_valid() {
277 assert_eq!(hashtag2("TAG:fubar").unwrap(), ("", "fubar"));
278 }
279
280 #[test]
281 fn tag_broken() {
282 assert_eq!(hashtag("#fu bar").unwrap(), (" bar", "fu"));
283 }
284
285 #[test]
286 fn tag_broken_noprefix() {
287 assert!(hashtag("asfd").is_err());
288 }
289
290 #[test]
291 fn metadata_pair_valid() {
292 assert_eq!(
293 metadata_pair("x-meta=value").unwrap(),
294 ("", ("x-meta", "value"))
295 );
296 }
297
298 #[test]
299 fn project_valid() {
300 assert_eq!(project("@fubar").unwrap(), ("", "fubar"));
301 }
302
303 #[test]
304 fn project2_valid() {
305 assert_eq!(project2("PRJ:fubar").unwrap(), ("", "fubar"));
306 }
307
308 #[test]
309 fn metadata_pair_broken() {
310 assert!(metadata_pair("x-meta = value").is_err());
311 }
312
313 #[test]
314 fn parse_full_testcase() {
315 let input = "some task description here @project-here #taghere #a-second-tag %x-meta=data %fuu=bar additional text at the end";
316
317 let (leftover, mut meta) = parse_inline(input).unwrap();
318
319 assert_eq!(leftover, "");
320 assert_eq!(
322 meta.pop().unwrap(),
323 ExpressionPrototype::Description("additional text at the end")
324 );
325 assert_eq!(
326 meta.pop().unwrap(),
327 ExpressionPrototype::Metadata {
328 key: "fuu",
329 value: "bar"
330 }
331 );
332 assert_eq!(
333 meta.pop().unwrap(),
334 ExpressionPrototype::Metadata {
335 key: "x-meta",
336 value: "data"
337 }
338 );
339 assert_eq!(
340 meta.pop().unwrap(),
341 ExpressionPrototype::Tag("a-second-tag")
342 );
343 assert_eq!(meta.pop().unwrap(), ExpressionPrototype::Tag("taghere"));
344 assert_eq!(
345 meta.pop().unwrap(),
346 ExpressionPrototype::Project("project-here")
347 );
348 assert_eq!(
349 meta.pop().unwrap(),
350 ExpressionPrototype::Description("some task description here")
351 );
352 }
353
354 #[test]
355 fn parse_full_testcase2() {
356 let input = "some task description here PRJ:project-here #taghere TAG:a-second-tag META:x-meta=data %fuu=bar DUE:2022-08-16T16:56:00 PRIO:medium and some text at the end";
357
358 let (leftover, mut meta) = parse_inline(input).unwrap();
359
360 assert_eq!(leftover, "");
361 assert_eq!(
363 meta.pop().unwrap(),
364 ExpressionPrototype::Description("and some text at the end")
365 );
366 assert_eq!(meta.pop().unwrap(), ExpressionPrototype::Priority("medium"));
367 assert_eq!(
368 meta.pop().unwrap(),
369 ExpressionPrototype::Duedate("2022-08-16T16:56:00")
370 );
371 assert_eq!(
372 meta.pop().unwrap(),
373 ExpressionPrototype::Metadata {
374 key: "fuu",
375 value: "bar"
376 }
377 );
378 assert_eq!(
379 meta.pop().unwrap(),
380 ExpressionPrototype::Metadata {
381 key: "x-meta",
382 value: "data"
383 }
384 );
385 assert_eq!(
386 meta.pop().unwrap(),
387 ExpressionPrototype::Tag("a-second-tag")
388 );
389 assert_eq!(meta.pop().unwrap(), ExpressionPrototype::Tag("taghere"));
390 assert_eq!(
391 meta.pop().unwrap(),
392 ExpressionPrototype::Project("project-here")
393 );
394 assert_eq!(
395 meta.pop().unwrap(),
396 ExpressionPrototype::Description("some task description here")
397 );
398 }
399
400 #[test]
401 fn parse_full_testcase_no_expressions() {
402 let input = "some task description here without expressions";
403
404 let (leftover, mut meta) = parse_inline(input).unwrap();
405
406 assert_eq!(leftover, "");
407 assert_eq!(meta.pop().unwrap(), ExpressionPrototype::Description(input));
409 assert!(meta.is_empty());
411 }
412}