agd_core/document/
checklist.rs1use std::sync::LazyLock;
10
11use regex::Regex;
12
13use super::{ByteSpan, HeadlineSpan};
14use crate::{AgdError, ErrorCode, Progress};
15
16static ITEM: LazyLock<Regex> = LazyLock::new(|| {
20 Regex::new(r"^([ \t]*)(?:[-+]|\*|[0-9]+[.)])(?:[ \t]+(?:\[@(?:start:)?[0-9]+\][ \t]*)?(.*))?$")
21 .expect("list item pattern")
22});
23
24static CHECKBOX: LazyLock<Regex> =
26 LazyLock::new(|| Regex::new(r"^\[([ X-])\](?:[ \t]+|$)").expect("checkbox pattern"));
27
28pub(crate) static COOKIE: LazyLock<Regex> =
30 LazyLock::new(|| Regex::new(r"\[(?:[0-9]*%|[0-9]*/[0-9]*)\]").expect("cookie pattern"));
31
32#[derive(Clone, Debug, Eq, PartialEq)]
34pub struct ChecklistItem {
35 pub text: String,
37 pub checked: bool,
39 pub(crate) mark: ByteSpan,
41}
42
43pub 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 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
116pub(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}