Skip to main content

agd_core/document/
checklist.rs

1//! An entry's checklist: the `- [ ] step` lines of its body, as Org's
2//! org-toggle-checkbox and org-update-checkbox-count read them.
3//!
4//! agd checks off a flat checklist only. A nested list, or a statistics
5//! cookie in the body or on an item, makes Org update parent boxes and
6//! counts that agd does not reproduce, so such an entry is refused and
7//! edited in $EDITOR instead.
8
9use std::sync::LazyLock;
10
11use regex::Regex;
12
13use super::{ByteSpan, HeadlineSpan};
14use crate::{AgdError, ErrorCode, Progress};
15
16/// A list item: indentation, bullet (`-`, `+`, an indented `*`, `1.` or
17/// `1)`; Org's default allows no letters), an optional `[@3]` counter,
18/// then the rest of the line.
19static ITEM: LazyLock<Regex> = LazyLock::new(|| {
20    Regex::new(r"^([ \t]*)(?:[-+]|\*|[0-9]+[.)])(?:[ \t]+(?:\[@(?:start:)?[0-9]+\][ \t]*)?(.*))?$")
21        .expect("list item pattern")
22});
23
24/// A checkbox at the start of an item's text: `[ ]`, `[X]` or `[-]`.
25static CHECKBOX: LazyLock<Regex> =
26    LazyLock::new(|| Regex::new(r"^\[([ X-])\](?:[ \t]+|$)").expect("checkbox pattern"));
27
28/// Org's statistics cookie: `[2/5]`, `[40%]`, `[/]` or `[%]`.
29pub(crate) static COOKIE: LazyLock<Regex> =
30    LazyLock::new(|| Regex::new(r"\[(?:[0-9]*%|[0-9]*/[0-9]*)\]").expect("cookie pattern"));
31
32/// One checklist line.
33#[derive(Clone, Debug, Eq, PartialEq)]
34pub struct ChecklistItem {
35    /// The text after the box.
36    pub text: String,
37    /// `[X]`; `[ ]` and `[-]` are open.
38    pub checked: bool,
39    /// The one byte inside the brackets, which checking off replaces.
40    pub(crate) mark: ByteSpan,
41}
42
43/// The checklist in `headline`'s body, in order; empty when it has none.
44/// Lines in drawers and blocks are not list items.
45pub fn checklist(source: &str, headline: &HeadlineSpan) -> Result<Vec<ChecklistItem>, AgdError> {
46    let mut items = Vec::new();
47    let mut indents = Vec::new();
48    let mut in_drawer = false;
49    let mut in_block = false;
50    let mut start = headline.body_span.start;
51    let body = &source[headline.body_span.start..headline.body_span.end];
52    for line in body.split_inclusive('\n') {
53        let line_start = start;
54        start += line.len();
55        let text = line.trim_end_matches(['\n', '\r']);
56        let trimmed = text.trim();
57        let lower = trimmed.to_ascii_lowercase();
58        if in_block {
59            in_block = !lower.starts_with("#+end_");
60            continue;
61        }
62        if in_drawer {
63            in_drawer = !trimmed.eq_ignore_ascii_case(":END:");
64            continue;
65        }
66        if lower.starts_with("#+begin_") {
67            in_block = true;
68            continue;
69        }
70        if trimmed.len() > 2 && trimmed.starts_with(':') && trimmed.ends_with(':') {
71            in_drawer = true;
72            continue;
73        }
74        let Some(item) = ITEM.captures(text) else {
75            if COOKIE.is_match(text) {
76                return Err(unsupported("a statistics cookie in its body"));
77            }
78            continue;
79        };
80        let indent = item.get(1).map_or("", |indent| indent.as_str());
81        // A `*` bullet at the margin is a heading, not an item.
82        if indent.is_empty() && trimmed.starts_with('*') {
83            continue;
84        }
85        indents.push(indent.len());
86        let Some(rest) = item.get(2) else {
87            continue;
88        };
89        let Some(checkbox) = CHECKBOX.captures(rest.as_str()) else {
90            if COOKIE.is_match(rest.as_str()) {
91                return Err(unsupported("a statistics cookie on a list item"));
92            }
93            continue;
94        };
95        let mark = checkbox.get(1).expect("checkbox mark");
96        let item_text = rest.as_str()[checkbox.get(0).expect("checkbox").end()..].to_string();
97        if COOKIE.is_match(&item_text) {
98            return Err(unsupported("a statistics cookie on a list item"));
99        }
100        let mark_start = line_start + rest.start() + mark.start();
101        items.push(ChecklistItem {
102            text: item_text,
103            checked: mark.as_str() == "X",
104            mark: ByteSpan {
105                start: mark_start,
106                end: mark_start + 1,
107            },
108        });
109    }
110    if indents.iter().any(|indent| Some(indent) != indents.first()) {
111        return Err(unsupported("a nested list"));
112    }
113    Ok(items)
114}
115
116/// What lists show for `headline`'s checklist: checked of all boxes. None
117/// without a checklist, or when the heading has its own cookie, which
118/// already says it.
119pub(crate) fn progress(source: &str, headline: &HeadlineSpan) -> Option<Progress> {
120    if COOKIE.is_match(&headline.item.title) {
121        return None;
122    }
123    let items = checklist(source, headline).ok()?;
124    (!items.is_empty()).then(|| Progress {
125        done: items.iter().filter(|item| item.checked).count(),
126        total: items.len(),
127    })
128}
129
130fn unsupported(what: &str) -> AgdError {
131    AgdError::new(
132        ErrorCode::InvalidEdit,
133        format!("this entry has {what}; check it off in $EDITOR"),
134    )
135}
136
137#[cfg(test)]
138mod tests {
139    use std::path::PathBuf;
140
141    use super::*;
142    use crate::Settings;
143    use crate::document::OrgDocument;
144
145    fn items(source: &str) -> Result<Vec<(String, bool)>, AgdError> {
146        let document = OrgDocument::parse(
147            PathBuf::from("tasks.org"),
148            source.as_bytes().to_vec(),
149            &Settings::default(),
150        )
151        .unwrap();
152        checklist(document.source(), &document.headlines()[0]).map(|items| {
153            items
154                .into_iter()
155                .map(|item| (item.text, item.checked))
156                .collect()
157        })
158    }
159
160    #[test]
161    fn a_checklist_skips_drawers_and_blocks_and_stops_at_the_next_heading() {
162        let source = "* TODO Plan [1/2]\n:LOGBOOK:\n- [ ] not an item\n:END:\n\
163#+begin_example\n- [ ] not one either\n#+end_example\n\
164- [X] book venue\n- [ ] send invites\n- plain item\n* TODO Next\n- [ ] other\n";
165        assert_eq!(
166            items(source).unwrap(),
167            [
168                ("book venue".to_string(), true),
169                ("send invites".to_string(), false)
170            ]
171        );
172    }
173
174    #[test]
175    fn an_item_carries_its_progress_unless_its_heading_has_a_cookie() {
176        let progress = |source: &str| {
177            OrgDocument::parse(
178                PathBuf::from("tasks.org"),
179                source.as_bytes().to_vec(),
180                &Settings::default(),
181            )
182            .unwrap()
183            .headlines()[0]
184                .item
185                .checklist
186        };
187        let steps = "- [X] venue\n- [ ] invites\n- [ ] lunch\n";
188        assert_eq!(
189            progress(&format!("* TODO Plan\n{steps}")),
190            Some(crate::Progress { done: 1, total: 3 })
191        );
192        assert_eq!(progress(&format!("* TODO Plan [1/3]\n{steps}")), None);
193        assert_eq!(progress("* TODO Plan\nNo steps.\n"), None);
194    }
195
196    #[test]
197    fn a_nested_list_or_a_cookie_outside_the_heading_is_left_to_the_editor() {
198        let nested = "* TODO Plan\n- [ ] venue\n  - [ ] call\n";
199        let in_body = "* TODO Plan\nDone [1/2] so far.\n- [ ] venue\n";
200        let on_item = "* TODO Plan\n- [ ] venue [0/1]\n";
201        for source in [nested, in_body, on_item] {
202            let error = items(source).unwrap_err();
203            assert_eq!(error.code(), ErrorCode::InvalidEdit, "{source}");
204            assert!(error.to_string().contains("$EDITOR"), "{error}");
205        }
206    }
207}