Skip to main content

agd_core/document/
directive.rs

1//! In-file directives and the settings they override: TODO keyword sequences
2//! with their logging markers (`#+TODO`, `#+SEQ_TODO`, `#+TYP_TODO`), logging
3//! from `#+STARTUP`, the priority range from `#+PRIORITIES`, and the readers
4//! for `#+KEY:` lines.
5//!
6//! A file's own lines replace the configured ones; a malformed `#+PRIORITIES`
7//! keeps the configured range rather than guessing.
8
9use crate::{LogMode, Settings};
10
11/// How a state change or planning change is recorded, as in Org's `!` (time)
12/// and `@` (note) markers and the org-log-* variables.
13#[derive(Clone, Copy, Debug, Eq, PartialEq)]
14pub enum LogKind {
15    Time,
16    Note,
17}
18
19/// One TODO keyword with the logging markers from `KEYWORD(key!/@)`.
20#[derive(Clone, Debug, Eq, PartialEq)]
21pub struct TodoKeyword {
22    pub name: String,
23    pub done: bool,
24    /// Index of the sequence it belongs to; repeaters reset to its first keyword.
25    pub sequence: usize,
26    pub log_enter: Option<LogKind>,
27    pub log_leave: Option<LogKind>,
28}
29
30/// A file's priority cookies, highest to lowest, and the priority of a
31/// heading without one: `#+PRIORITIES: A C B` (org-priority-highest,
32/// -lowest and -default), else the configured range.
33#[derive(Clone, Copy, Debug, Eq, PartialEq)]
34pub struct PriorityRange {
35    pub highest: char,
36    pub lowest: char,
37    pub default: char,
38}
39
40impl PriorityRange {
41    /// The configured range (`highest_priority`, `lowest_priority`,
42    /// `default_priority`).
43    pub fn from_settings(settings: &Settings) -> Self {
44        let first = |value: &str, fallback: char| value.chars().next().unwrap_or(fallback);
45        Self {
46            highest: first(&settings.highest_priority, 'A'),
47            lowest: first(&settings.lowest_priority, 'C'),
48            default: first(&settings.default_priority, 'B'),
49        }
50    }
51
52    /// Whether `priority` is one cookie character within the range.
53    pub fn contains(&self, priority: &str) -> bool {
54        let mut characters = priority.chars();
55        match (characters.next(), characters.next()) {
56            (Some(value), None) => (self.highest..=self.lowest).contains(&value),
57            _ => false,
58        }
59    }
60
61    /// Every cookie from highest to lowest, as `[A, B, C]`.
62    pub fn cookies(&self) -> Vec<String> {
63        (self.highest..=self.lowest)
64            .map(|value| value.to_string())
65            .collect()
66    }
67
68    /// Org's numeric priority (org-get-priority): 1000 for each step above
69    /// the lowest cookie, a heading without one counting as the default.
70    pub fn value(&self, priority: Option<&str>) -> i64 {
71        let cookie = priority
72            .and_then(|value| value.chars().next())
73            .unwrap_or(self.default);
74        1000 * (self.lowest as i64 - cookie as i64)
75    }
76
77    /// `A–C`, for messages.
78    pub fn describe(&self) -> String {
79        format!("{}–{}", self.highest, self.lowest)
80    }
81}
82
83/// Effective logging for one file: configuration overridden by `#+STARTUP`.
84#[derive(Clone, Copy, Debug, Eq, PartialEq)]
85pub struct LogSettings {
86    pub done: Option<LogKind>,
87    pub repeat: Option<LogKind>,
88    pub reschedule: Option<LogKind>,
89    pub redeadline: Option<LogKind>,
90    pub into_drawer: bool,
91}
92
93/// TODO keywords from every `#+TODO:`/`#+SEQ_TODO:`/`#+TYP_TODO:` line, or
94/// the configured `todo_keywords` lines, which hold the same text. A
95/// sequence without `|` makes its last keyword the done state, as in Org.
96pub(crate) fn todo_keywords(source: &str, settings: &Settings) -> Vec<TodoKeyword> {
97    let in_file: Vec<&str> = directives(source, &["TODO", "SEQ_TODO", "TYP_TODO"]).collect();
98    let lines = if in_file.is_empty() {
99        settings.todo_keywords.iter().map(String::as_str).collect()
100    } else {
101        in_file
102    };
103    let mut keywords = Vec::new();
104    for (index, line) in lines.iter().enumerate() {
105        let sequence: Vec<&str> = line.split_whitespace().collect();
106        let separator = sequence.iter().position(|word| *word == "|");
107        let last = sequence.len().saturating_sub(1);
108        for (position, raw) in sequence.iter().enumerate() {
109            if *raw == "|" {
110                continue;
111            }
112            let (name, markers) = raw.split_once('(').map_or((*raw, ""), |(name, rest)| {
113                (name, rest.trim_end_matches(')'))
114            });
115            if name.is_empty() {
116                continue;
117            }
118            let (enter, leave) = markers.split_once('/').unwrap_or((markers, ""));
119            keywords.push(TodoKeyword {
120                name: name.to_string(),
121                done: separator.map_or(position == last, |separator| position > separator),
122                sequence: index,
123                log_enter: log_marker(enter),
124                log_leave: log_marker(leave),
125            });
126        }
127    }
128    keywords
129}
130
131fn log_marker(marker: &str) -> Option<LogKind> {
132    if marker.contains('@') {
133        Some(LogKind::Note)
134    } else if marker.contains('!') {
135        Some(LogKind::Time)
136    } else {
137        None
138    }
139}
140
141fn log_value(mode: LogMode) -> Option<LogKind> {
142    match mode {
143        LogMode::Off => None,
144        LogMode::Time => Some(LogKind::Time),
145        LogMode::Note => Some(LogKind::Note),
146    }
147}
148
149/// org-log-* settings for one file, with `#+STARTUP:` options such as
150/// `logrepeat`, `lognotereschedule` or `nologdrawer` applied on top.
151pub(super) fn log_settings(source: &str, settings: &Settings) -> LogSettings {
152    let agenda = settings;
153    let mut logging = LogSettings {
154        done: log_value(agenda.log_done),
155        repeat: log_value(agenda.log_repeat),
156        reschedule: log_value(agenda.log_reschedule),
157        redeadline: log_value(agenda.log_redeadline),
158        into_drawer: agenda.log_into_drawer,
159    };
160    for option in directives(source, &["STARTUP"]).flat_map(str::split_whitespace) {
161        let option = option.to_ascii_lowercase();
162        let (kind, name) = if let Some(name) = option.strip_prefix("nolog") {
163            (None, name)
164        } else if let Some(name) = option.strip_prefix("lognote") {
165            (Some(LogKind::Note), name)
166        } else if let Some(name) = option.strip_prefix("log") {
167            (Some(LogKind::Time), name)
168        } else {
169            continue;
170        };
171        match name {
172            "done" => logging.done = kind,
173            "repeat" => logging.repeat = kind,
174            "reschedule" => logging.reschedule = kind,
175            "redeadline" => logging.redeadline = kind,
176            "drawer" => logging.into_drawer = kind.is_some(),
177            _ => {}
178        }
179    }
180    logging
181}
182
183/// The file's priority range: its `#+PRIORITIES: highest lowest default`
184/// line when agd can use it, else the configured range.
185pub(super) fn priority_range(source: &str, settings: &Settings) -> PriorityRange {
186    directive(source, "PRIORITIES")
187        .and_then(priorities_line)
188        .unwrap_or_else(|| PriorityRange::from_settings(settings))
189}
190
191/// A `#+PRIORITIES` line agd ignores, keeping the configured range: one
192/// with numeric priorities, which agd does not support, or one that is not
193/// three letters running highest, lowest, default. `agd setup check`
194/// reports it.
195pub(crate) fn ignored_priorities_line(source: &str) -> Option<&str> {
196    directive(source, "PRIORITIES").filter(|value| priorities_line(value).is_none())
197}
198
199/// `A C B`: three uppercase letters, highest before lowest, the default
200/// between them.
201fn priorities_line(value: &str) -> Option<PriorityRange> {
202    let letters: Vec<char> = value
203        .split_whitespace()
204        .map(|token| {
205            let mut characters = token.chars();
206            match (characters.next(), characters.next()) {
207                (Some(letter @ 'A'..='Z'), None) => Some(letter),
208                _ => None,
209            }
210        })
211        .collect::<Option<_>>()?;
212    match letters.as_slice() {
213        [highest, lowest, default]
214            if highest <= lowest && (highest..=lowest).contains(&default) =>
215        {
216            Some(PriorityRange {
217                highest: *highest,
218                lowest: *lowest,
219                default: *default,
220            })
221        }
222        _ => None,
223    }
224}
225
226fn directives<'a>(source: &'a str, names: &'a [&str]) -> impl Iterator<Item = &'a str> {
227    source.lines().filter_map(move |line| {
228        let (key, value) = line.strip_prefix("#+")?.split_once(':')?;
229        names
230            .iter()
231            .any(|name| key.eq_ignore_ascii_case(name))
232            .then_some(value.trim())
233    })
234}
235
236pub(super) fn directive<'a>(source: &'a str, name: &str) -> Option<&'a str> {
237    source.lines().find_map(|line| {
238        let (key, value) = line.strip_prefix("#+")?.split_once(':')?;
239        key.eq_ignore_ascii_case(name).then_some(value.trim())
240    })
241}
242
243#[cfg(test)]
244mod tests {
245    use std::path::PathBuf;
246
247    use super::*;
248    use crate::document::OrgDocument;
249
250    #[test]
251    fn in_file_priorities_override_the_configured_range() {
252        let parse = |source: &str| {
253            OrgDocument::parse(
254                PathBuf::from("/tmp/p.org"),
255                source.as_bytes().to_vec(),
256                &Settings::default(),
257            )
258            .unwrap()
259            .priorities()
260        };
261        let in_file = parse("#+PRIORITIES: A E C\n* TODO [#E] Low\n");
262        assert_eq!(
263            in_file,
264            PriorityRange {
265                highest: 'A',
266                lowest: 'E',
267                default: 'C'
268            }
269        );
270        assert!(in_file.contains("E"));
271        assert!(!in_file.contains("F"));
272        assert_eq!(in_file.value(None), in_file.value(Some("C")));
273        assert!(in_file.value(Some("A")) > in_file.value(Some("E")));
274        // Numeric and malformed lines keep the configured A-C, default B.
275        for source in [
276            "#+PRIORITIES: 1 5 3\n",
277            "#+PRIORITIES: C A B\n",
278            "#+PRIORITIES: A C\n",
279            "#+PRIORITIES: 1 10 5\n",
280        ] {
281            assert_eq!(
282                parse(source),
283                PriorityRange::from_settings(&Settings::default())
284            );
285            assert!(ignored_priorities_line(source).is_some(), "{source}");
286        }
287        assert_eq!(ignored_priorities_line("#+PRIORITIES: A E C\n"), None);
288    }
289}