tsk_rs/parser/
task_lexicon.rs

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// https://imfeld.dev/writing/parsing_with_nom
16// https://github.com/Geal/nom/blob/main/doc/choosing_a_combinator.md
17#[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/// Expression components
28#[derive(Debug, PartialEq, Eq)]
29pub enum Expression {
30    /// Description read from task definition string. Matches everything not matched elsewhere.
31    Description(String),
32    /// Project component from task definition string
33    Project(String),
34    /// Tag component from task definition string
35    Tag(String),
36    /// Metadata key=value pair component from task definition string
37    Metadata {
38        /// Key of the metadata value
39        key: String,
40        /// Value of the metadata
41        value: String,
42    },
43    /// Priority component from task definition string
44    Priority(TaskPriority),
45    /// Duedate component from task definition string
46    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            // println!("{} {}", current_index, current_input);
160            match directive(&current_input[current_index..]) {
161                Ok((remaining, parsed)) => {
162                    // println!("Matched {:?} remaining {}", parsed, remaining);
163                    let leading_text = &current_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                    // None of the parsers matched at the current position, so this character is just part of the text.
175                    // The iterator will go to the next character so there's nothing to do here.
176                }
177                Err(e) => {
178                    // On any other error, just return the error.
179                    return Err(e);
180                }
181            }
182        }
183
184        if !found_directive {
185            // no directives matched so just add the text as is into the Description
186            output.push(ExpressionPrototype::Description(current_input.trim()));
187            break;
188        }
189    }
190
191    Ok(("", output))
192}
193
194/// Errors that can happend during parsing of the task descriptor string
195#[derive(Error, Debug, PartialEq, Eq)]
196pub enum LexiconError {
197    /// There was a problem while parsing the descriptor string
198    #[error("i got confused by the language")]
199    ParserError(String),
200}
201
202/// Parses descriptor string based on known lexicon and returns result of elements
203pub 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 the expressions from Vec
321        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 the expressions from Vec
362        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        // after pulling single description expresssion out of the vec
408        assert_eq!(meta.pop().unwrap(), ExpressionPrototype::Description(input));
409        // ... check that the vec is actually now empty
410        assert!(meta.is_empty());
411    }
412}