vault_tasks_core/parser/
parser_file_entry.rs

1use std::iter::Peekable;
2
3use color_eyre::{eyre::bail, Result};
4use tracing::{debug, error};
5use winnow::{
6    ascii::{space0, space1},
7    combinator::{alt, preceded, repeat},
8    token::{take_till, take_while},
9    PResult, Parser,
10};
11
12use crate::{task::Task, vault_data::VaultData, TasksConfig};
13
14use super::task::parse_task;
15
16enum FileToken {
17    /// Name, Heading level
18    Header((String, usize)),
19    /// Content, Indent length
20    Description(String, usize),
21    /// Task, Indent length
22    Task(Task, usize),
23    /// A tag found outside a task in the file
24    FileTag(String),
25}
26
27#[allow(clippy::module_name_repetitions)]
28pub struct ParserFileEntry<'a> {
29    pub config: &'a TasksConfig,
30    pub filename: String,
31}
32
33impl ParserFileEntry<'_> {
34    fn parse_indent(input: &mut &str) -> PResult<usize> {
35        let indent_length: String = repeat(1.., " ").parse_next(input)?;
36        Ok(indent_length.len())
37    }
38    fn parse_task(&self, input: &mut &str) -> PResult<FileToken> {
39        let indent_length = Self::parse_indent(input).unwrap_or(0);
40
41        let mut task_parser =
42            |input: &mut &str| parse_task(input, self.filename.clone(), self.config);
43        let task_res = task_parser.parse_next(input)?;
44        Ok(FileToken::Task(task_res, indent_length))
45    }
46    fn parse_header(input: &mut &str) -> PResult<FileToken> {
47        let header_depth: String = repeat(1.., "#").parse_next(input)?;
48        let header_content = preceded(space0, take_till(1.., |c| c == '\n')).parse_next(input)?;
49
50        Ok(FileToken::Header((
51            header_content.to_string(),
52            header_depth.len(),
53        )))
54    }
55    fn parse_description(input: &mut &str) -> PResult<FileToken> {
56        let indent_length = space1.map(|s: &str| s.len()).parse_next(input)?;
57        let desc_content = take_till(1.., |c| c == '\n').parse_next(input)?;
58        Ok(FileToken::Description(
59            desc_content.to_string(),
60            indent_length,
61        ))
62    }
63    fn parse_file_tag(input: &mut &str) -> PResult<FileToken> {
64        let tag = preceded(
65            '#',
66            take_while(1.., ('_', '0'..='9', 'A'..='Z', 'a'..='z', '0'..='9')),
67        )
68        .parse_next(input)?;
69        Ok(FileToken::FileTag(tag.to_owned()))
70    }
71    fn insert_task_at(
72        file_entry: &mut VaultData,
73        task: Task,
74        header_depth: usize,
75        indent_length: usize,
76    ) -> Result<()> {
77        fn append_task_aux(
78            file_entry: &mut VaultData,
79            task_to_insert: Task,
80            current_header_depth: usize,
81            target_header_depth: usize,
82            current_task_depth: usize,
83            target_task_depth: usize,
84        ) -> Result<()> {
85            match file_entry {
86                VaultData::Header(_, _, header_children) => {
87                    match current_header_depth.cmp(&target_header_depth) {
88                        std::cmp::Ordering::Greater => panic!("Target header level was greater than current level which is impossible"), // shouldn't happen
89                        std::cmp::Ordering::Equal => {
90                            // Found correct header level
91                            if current_task_depth == target_task_depth {
92                                header_children.push(VaultData::Task(task_to_insert));
93                                Ok(())
94                            } else {
95                                for child in header_children.iter_mut().rev() {
96                                    if let VaultData::Task(_task) = child {
97                                        return append_task_aux(
98                                            child,
99                                            task_to_insert,
100                                            current_header_depth,
101                                            target_header_depth,
102                                            current_task_depth + 1,
103                                            target_task_depth,
104                                        );
105                                    }
106                                }
107                                bail!("Cound't find correct parent task to insert task {}", task_to_insert.name)
108                            }
109                        }
110                        std::cmp::Ordering::Less => {
111                            // Going deeper in header levels
112                            for child in header_children.iter_mut().rev() {
113                                if let VaultData::Header(_,_, _) = child {
114                                    return append_task_aux(
115                                        child,
116                                        task_to_insert,
117                                        current_header_depth + 1,
118                                        target_header_depth,
119                                        current_task_depth,
120                                        target_task_depth,
121                                    );
122                                }
123                            }
124                                bail!("Cound't find correct parent header to insert task {}", task_to_insert.name)
125                        }
126                    }
127                }
128                VaultData::Task(task) => {
129                    let mut current_task_depth = current_task_depth;
130                    let mut last_task = task;
131                    while current_task_depth < target_task_depth {
132                        if last_task.subtasks.is_empty() {
133                            error!("Could not find parent task, indenting may be wrong. Closest task line number: {}",last_task.line_number);
134                            bail!("Failed to insert task")
135                        }
136                        last_task = last_task.subtasks.last_mut().unwrap();
137                        current_task_depth += 1;
138                    }
139                    last_task.subtasks.push(task_to_insert);
140                    Ok(())
141                }
142                VaultData::Directory(_, _) => {
143                    bail!("Failed to insert task: tried to insert into a directory")
144                }
145            }
146        }
147        append_task_aux(file_entry, task, 0, header_depth, 0, indent_length)
148    }
149    /// Inserts a `FileEntry` at the specific header `depth` in `file_entry`.
150    fn insert_header_at(
151        file_entry: &mut VaultData,
152        object: VaultData,
153        target_header_depth: usize,
154        target_task_depth: usize,
155    ) {
156        fn insert_at_aux(
157            file_entry: &mut VaultData,
158            object: VaultData,
159            current_header_depth: usize,
160            target_header_depth: usize,
161            current_task_depth: usize,
162            target_task_depth: usize,
163        ) {
164            match file_entry {
165                VaultData::Header(_, _, header_children) => {
166                    match (current_header_depth).cmp(&target_header_depth) {
167                        std::cmp::Ordering::Greater => error!(
168                            "bad call to `insert_at`, file_entry:{file_entry}\nobject:{object}"
169                        ), // shouldn't happen
170                        std::cmp::Ordering::Equal => {
171                            // Found correct header level
172                            if current_task_depth == target_task_depth {
173                                header_children.push(object);
174                            } else {
175                                for child in header_children.iter_mut().rev() {
176                                    if let VaultData::Task(_) = child {
177                                        return insert_at_aux(
178                                            child,
179                                            object,
180                                            current_header_depth,
181                                            target_header_depth,
182                                            current_task_depth + 1,
183                                            target_task_depth,
184                                        );
185                                    }
186                                }
187                            }
188                        }
189                        std::cmp::Ordering::Less => {
190                            // Still haven't found correct header level, going deeper
191                            for child in header_children.iter_mut().rev() {
192                                if let VaultData::Header(_, _, _) = child {
193                                    insert_at_aux(
194                                        child,
195                                        object,
196                                        current_header_depth + 1,
197                                        target_header_depth,
198                                        current_task_depth,
199                                        target_task_depth,
200                                    );
201                                    return;
202                                }
203                            }
204                            header_children.push(object);
205                        }
206                    }
207                }
208                VaultData::Task(_) => {
209                    error!("Error: tried to insert a header into a task");
210                }
211                VaultData::Directory(name, _) => {
212                    error!("Error: tried to insert a header into a directory : {name}");
213                }
214            }
215        }
216        insert_at_aux(
217            file_entry,
218            object,
219            0,
220            target_header_depth,
221            0,
222            target_task_depth,
223        );
224    }
225
226    /// Appends `desc` to the description of an existing `Task` in the `FileEntry`.
227    fn append_description(
228        file_entry: &mut VaultData,
229        desc: String,
230        target_header_depth: usize,
231        target_task_depth: usize,
232    ) -> Result<()> {
233        fn append_description_aux(
234            file_entry: &mut VaultData,
235            desc: String,
236            current_header_depth: usize,
237            target_header_depth: usize,
238            current_task_depth: usize,
239            target_task_depth: usize,
240        ) -> Result<()> {
241            match file_entry {
242                VaultData::Header(_, _, header_children) => {
243                    match current_header_depth.cmp(&target_header_depth) {
244                        std::cmp::Ordering::Greater => panic!("bad call for desc"), // shouldn't happen
245                        std::cmp::Ordering::Equal => {
246                            // Found correct header level
247                            for child in header_children.iter_mut().rev() {
248                                if let VaultData::Task(mut task) = child.clone() {
249                                    if current_task_depth == target_task_depth {
250                                        match &mut task.description {
251                                            Some(d) => {
252                                                d.push('\n');
253                                                d.push_str(&desc.clone());
254                                            }
255                                            None => task.description = Some(desc.clone()),
256                                        }
257                                        *child = VaultData::Task(task);
258                                    } else {
259                                        return append_description_aux(
260                                            child,
261                                            desc,
262                                            current_header_depth,
263                                            target_header_depth,
264                                            current_task_depth + 1,
265                                            target_task_depth,
266                                        );
267                                    }
268                                }
269                            }
270                            Ok(())
271                        }
272                        std::cmp::Ordering::Less => {
273                            // Going deeper in header levels
274                            for child in header_children.iter_mut().rev() {
275                                if let VaultData::Header(_, _, _) = child {
276                                    return append_description_aux(
277                                        child,
278                                        desc,
279                                        current_header_depth + 1,
280                                        target_header_depth,
281                                        current_task_depth,
282                                        target_task_depth,
283                                    );
284                                }
285                            }
286                            bail!("Failed to insert description: previous task not found");
287                        }
288                    }
289                }
290                VaultData::Task(task) => {
291                    fn insert_desc_task(
292                        description: String,
293                        task: &mut Task,
294                        current_level: usize,
295                        target_level: usize,
296                    ) -> Result<()> {
297                        if current_level == target_level {
298                            match &mut task.description {
299                                Some(d) => {
300                                    d.push('\n');
301                                    d.push_str(&description);
302                                    Ok(())
303                                }
304                                None => {
305                                    task.description = Some(description.clone());
306                                    Ok(())
307                                }
308                            }
309                        } else if let Some(task) = task.subtasks.last_mut() {
310                            insert_desc_task(description, task, current_level + 1, target_level)
311                        } else {
312                            debug!("Description was too indented, adding to closest task: {description}");
313                            insert_desc_task(description, task, current_level + 1, target_level)
314                        }
315                    }
316                    insert_desc_task(desc, task, current_task_depth, target_task_depth)
317                }
318                VaultData::Directory(_, _) => {
319                    bail!("Failed to insert description: tried to insert into a directory")
320                }
321            }
322        }
323        append_description_aux(
324            file_entry,
325            desc,
326            0,
327            target_header_depth,
328            0,
329            target_task_depth,
330        )
331    }
332
333    /// Recursively parses the input file passed as a string.
334    fn parse_file_aux<'a, I>(
335        &self,
336        mut input: Peekable<I>,
337        file_entry: &mut VaultData,
338        file_tags: &mut Vec<String>,
339        header_depth: usize,
340    ) where
341        I: Iterator<Item = (usize, &'a str)>,
342    {
343        let mut parser = alt((
344            Self::parse_file_tag,
345            Self::parse_header,
346            |input: &mut &str| self.parse_task(input),
347            Self::parse_description,
348        ));
349
350        let line_opt = input.next();
351        if line_opt.is_none() {
352            return;
353        }
354
355        let (line_number, mut line) = line_opt.unwrap();
356
357        match parser.parse_next(&mut line) {
358            Ok(FileToken::Task(mut task, indent_length)) => {
359                task.line_number = line_number + 1; // line 1 was element 0 of iterator
360                if Self::insert_task_at(
361                    file_entry,
362                    task,
363                    header_depth,
364                    indent_length / self.config.indent_length,
365                )
366                .is_err()
367                {
368                    error!("Failed to insert task");
369                }
370                self.parse_file_aux(input, file_entry, file_tags, header_depth);
371            }
372            Ok(FileToken::Header((header, new_depth))) => {
373                Self::insert_header_at(
374                    file_entry,
375                    VaultData::Header(new_depth, header, vec![]),
376                    new_depth - 1,
377                    0,
378                );
379                self.parse_file_aux(input, file_entry, file_tags, new_depth);
380            }
381            Ok(FileToken::Description(description, indent_length)) => {
382                if Self::append_description(
383                    file_entry,
384                    description.clone(),
385                    header_depth,
386                    indent_length / self.config.indent_length,
387                )
388                .is_err()
389                {
390                    error!("Failed to insert description {description}");
391                }
392                self.parse_file_aux(input, file_entry, file_tags, header_depth);
393            }
394            Ok(FileToken::FileTag(tag)) => {
395                if !file_tags.contains(&tag) {
396                    file_tags.push(tag);
397                }
398                self.parse_file_aux(input, file_entry, file_tags, header_depth);
399            }
400            Err(_) => self.parse_file_aux(input, file_entry, file_tags, header_depth),
401        }
402    }
403
404    /// Removes any empty headers from a `FileEntry`
405    fn clean_file_entry(file_entry: &mut VaultData) -> Option<&VaultData> {
406        match file_entry {
407            VaultData::Header(_, _, children) | VaultData::Directory(_, children) => {
408                let mut actual_children = vec![];
409                for child in children.iter_mut() {
410                    let mut child_clone = child.clone();
411                    if Self::clean_file_entry(&mut child_clone).is_some() {
412                        actual_children.push(child_clone);
413                    }
414                }
415                *children = actual_children;
416                if children.is_empty() {
417                    None
418                } else {
419                    Some(file_entry)
420                }
421            }
422            VaultData::Task(_) => Some(file_entry),
423        }
424    }
425
426    pub fn parse_file(&mut self, filename: &str, input: &&str) -> Option<VaultData> {
427        let lines = input.split('\n');
428
429        let mut res = VaultData::Header(0, filename.to_owned(), vec![]);
430        let mut file_tags = vec![];
431        self.filename = filename.to_string();
432        self.parse_file_aux(lines.enumerate().peekable(), &mut res, &mut file_tags, 0);
433
434        if self.config.file_tags_propagation {
435            file_tags.iter().for_each(|t| add_global_tag(&mut res, t));
436        }
437
438        // Filename is changed from Header to Directory variant at the end
439        if let Some(VaultData::Header(_, name, children)) = Self::clean_file_entry(&mut res) {
440            Some(VaultData::Directory(name.clone(), children.clone()))
441        } else {
442            None
443        }
444    }
445}
446
447fn add_global_tag(file_entry: &mut VaultData, tag: &String) {
448    fn add_tag_aux(file_entry: &mut VaultData, tag: &String) {
449        match file_entry {
450            VaultData::Header(_, _, children) | VaultData::Directory(_, children) => {
451                for child in children.iter_mut().rev() {
452                    add_tag_aux(child, tag);
453                }
454            }
455            VaultData::Task(task) => {
456                fn insert_tag_task(task: &mut Task, tag: &String) {
457                    match task.tags.clone() {
458                        Some(mut tags) if !tags.contains(tag) => {
459                            tags.push(tag.to_string());
460                            task.tags = Some(tags);
461                        }
462                        None => task.tags = Some(vec![tag.to_string()]),
463                        _ => (),
464                    }
465
466                    for st in &mut task.subtasks {
467                        insert_tag_task(st, tag);
468                    }
469                }
470                insert_tag_task(task, tag);
471            }
472        }
473    }
474    add_tag_aux(file_entry, tag);
475}
476#[cfg(test)]
477mod tests {
478
479    use insta::assert_snapshot;
480
481    use super::ParserFileEntry;
482
483    use crate::{
484        parser::parser_file_entry::add_global_tag, task::Task, vault_data::VaultData, TasksConfig,
485    };
486    #[test]
487    fn test_with_useless_headers() {
488        let input = r"# 1 useless
489## 2 useless
490### 3 useless
491
492# 2 useful
493### 3 useless
494## 4 useful
495- [ ] test
496  test
497  desc
498"
499        .split('\n')
500        .enumerate()
501        .peekable();
502
503        let config = TasksConfig {
504            indent_length: 2,
505            ..Default::default()
506        };
507        let mut res = VaultData::Header(0, "Test".to_string(), vec![]);
508        let parser = ParserFileEntry {
509            config: &config,
510            filename: String::new(),
511        };
512        let expected = VaultData::Header(
513            0,
514            "Test".to_string(),
515            vec![
516                VaultData::Header(
517                    1,
518                    "1 useless".to_string(),
519                    vec![VaultData::Header(
520                        2,
521                        "2 useless".to_string(),
522                        vec![VaultData::Header(3, "3 useless".to_string(), vec![])],
523                    )],
524                ),
525                VaultData::Header(
526                    1,
527                    "2 useful".to_string(),
528                    vec![
529                        VaultData::Header(3, "3 useless".to_string(), vec![]),
530                        VaultData::Header(
531                            2,
532                            "4 useful".to_string(),
533                            vec![VaultData::Task(Task {
534                                name: "test".to_string(),
535                                line_number: 8,
536                                description: Some("test\ndesc".to_string()),
537                                ..Default::default()
538                            })],
539                        ),
540                    ],
541                ),
542            ],
543        );
544        parser.parse_file_aux(input, &mut res, &mut vec![], 0);
545        assert_eq!(res, expected);
546
547        let expected_after_cleaning = VaultData::Header(
548            0,
549            "Test".to_string(),
550            vec![VaultData::Header(
551                1,
552                "2 useful".to_string(),
553                vec![VaultData::Header(
554                    2,
555                    "4 useful".to_string(),
556                    vec![VaultData::Task(Task {
557                        name: "test".to_string(),
558                        line_number: 8,
559                        description: Some("test\ndesc".to_string()),
560                        ..Default::default()
561                    })],
562                )],
563            )],
564        );
565        ParserFileEntry::clean_file_entry(&mut res);
566        assert_eq!(res, expected_after_cleaning);
567    }
568    #[test]
569    fn test_simple_input() {
570        let input = r"# 1 Header
571- [ ] Task
572
573## 2 Header
574### 3 Header
575- [ ] Task
576- [ ] Task 2
577## 2 Header 2
578- [ ] Task
579  Description
580
581"
582        .split('\n')
583        .enumerate()
584        .peekable();
585
586        let config = TasksConfig {
587            indent_length: 2,
588            ..Default::default()
589        };
590        let mut res = VaultData::Header(0, "Test".to_string(), vec![]);
591        let parser = ParserFileEntry {
592            config: &config,
593            filename: String::new(),
594        };
595        let expected = VaultData::Header(
596            0,
597            "Test".to_string(),
598            vec![VaultData::Header(
599                1,
600                "1 Header".to_string(),
601                vec![
602                    VaultData::Task(Task {
603                        name: "Task".to_string(),
604                        line_number: 2,
605                        ..Default::default()
606                    }),
607                    VaultData::Header(
608                        2,
609                        "2 Header".to_string(),
610                        vec![VaultData::Header(
611                            3,
612                            "3 Header".to_string(),
613                            vec![
614                                VaultData::Task(Task {
615                                    name: "Task".to_string(),
616                                    line_number: 6,
617                                    ..Default::default()
618                                }),
619                                VaultData::Task(Task {
620                                    name: "Task 2".to_string(),
621                                    line_number: 7,
622                                    ..Default::default()
623                                }),
624                            ],
625                        )],
626                    ),
627                    VaultData::Header(
628                        2,
629                        "2 Header 2".to_string(),
630                        vec![VaultData::Task(Task {
631                            name: "Task".to_string(),
632                            line_number: 9,
633                            description: Some("Description".to_string()),
634                            ..Default::default()
635                        })],
636                    ),
637                ],
638            )],
639        );
640        parser.parse_file_aux(input, &mut res, &mut vec![], 0);
641        assert_eq!(res, expected);
642    }
643    #[test]
644    fn test_insert_global_tag() {
645        let input = r"# 1 Header
646- [ ] Task
647
648## 2 Header
649### 3 Header
650- [ ] Task
651- [ ] Task 2
652## 2 Header 2
653- [ ] Task
654  Description
655
656"
657        .split('\n')
658        .enumerate()
659        .peekable();
660
661        let config = TasksConfig {
662            indent_length: 2,
663            ..Default::default()
664        };
665        let mut res = VaultData::Header(0, "Test".to_string(), vec![]);
666        let parser = ParserFileEntry {
667            config: &config,
668            filename: String::new(),
669        };
670        parser.parse_file_aux(input, &mut res, &mut vec![], 0);
671        add_global_tag(&mut res, &String::from("test"));
672        assert_snapshot!(res);
673    }
674    #[test]
675    fn test_fake_description() {
676        let input = r"# 1 Header
677  test
678- [ ] Task
679
680## 2 Header
681  test
682"
683        .split('\n')
684        .enumerate()
685        .peekable();
686
687        let config = TasksConfig {
688            indent_length: 2,
689            ..Default::default()
690        };
691        let mut res = VaultData::Header(0, "Test".to_string(), vec![]);
692        let parser = ParserFileEntry {
693            config: &config,
694            filename: String::new(),
695        };
696        let expected = VaultData::Header(
697            0,
698            "Test".to_string(),
699            vec![VaultData::Header(
700                1,
701                "1 Header".to_string(),
702                vec![
703                    VaultData::Task(Task {
704                        name: "Task".to_string(),
705                        line_number: 3,
706                        ..Default::default()
707                    }),
708                    VaultData::Header(2, "2 Header".to_string(), vec![]),
709                ],
710            )],
711        );
712        parser.parse_file_aux(input, &mut res, &mut vec![], 0);
713        assert_eq!(res, expected);
714    }
715    #[test]
716    fn test_nested_tasks() {
717        let input = r"# 1 Header
718## Test
719- [ ] Test a
720  - [ ] Test b
721    - [ ] Test c
722"
723        .split('\n')
724        .enumerate()
725        .peekable();
726
727        let config = TasksConfig {
728            indent_length: 2,
729            ..Default::default()
730        };
731        let mut res = VaultData::Header(0, "Test".to_string(), vec![]);
732        let parser = ParserFileEntry {
733            config: &config,
734            filename: String::new(),
735        };
736        let expected = VaultData::Header(
737            0,
738            "Test".to_string(),
739            vec![VaultData::Header(
740                1,
741                "1 Header".to_string(),
742                vec![VaultData::Header(
743                    2,
744                    "Test".to_string(),
745                    vec![VaultData::Task(Task {
746                        name: "Test a".to_string(),
747                        line_number: 3,
748                        subtasks: vec![Task {
749                            name: "Test b".to_string(),
750                            line_number: 4,
751                            subtasks: vec![Task {
752                                name: "Test c".to_string(),
753                                line_number: 5,
754                                ..Default::default()
755                            }],
756                            ..Default::default()
757                        }],
758                        ..Default::default()
759                    })],
760                )],
761            )],
762        );
763        parser.parse_file_aux(input, &mut res, &mut vec![], 0);
764        println!("{res:#?}");
765        assert_eq!(res, expected);
766    }
767    #[test]
768    fn test_nested_tasks_desc() {
769        let input = r"# 1 Header
770- [ ] t1
771  t1
772  - [ ] t2
773    t2
774  t1
775    t2
776    - [ ] t3
777    t2
778  t1
779      t3
780  t1
781      - [ ] t4
782    t2
783        t4
784      t3
785        t4
786
787"
788        .split('\n')
789        .enumerate()
790        .peekable();
791
792        let config = TasksConfig {
793            indent_length: 2,
794            ..Default::default()
795        };
796        let mut res = VaultData::Header(0, "Test".to_string(), vec![]);
797        let parser = ParserFileEntry {
798            config: &config,
799            filename: String::new(),
800        };
801        parser.parse_file_aux(input, &mut res, &mut vec![], 0);
802        assert_snapshot!(res);
803    }
804}