Skip to main content

agd_core/document/
headline.rs

1//! Headlines as the index sees them: `HeadlineSpan`, holding the `Item`, the
2//! level and the byte spans of the headline, planning line, property drawer,
3//! section and subtree, and the scan that builds them with tag and category
4//! inheritance.
5//!
6//! Planning is read only from the line right after the headline, as Org reads
7//! it. Tags and priority cookies are split off exactly as the parser reads
8//! them (`split_trailing_tags`, `leading_priority_cookie`); writes use the same
9//! functions to refuse a title that would read back differently.
10
11use std::collections::BTreeSet;
12use std::path::Path;
13use std::sync::LazyLock;
14
15use regex::Regex;
16use sha2::{Digest, Sha256};
17
18use crate::{AgdError, ErrorCode, ITEM_SCHEMA, Item, Planning, PlanningTimestamp, Settings};
19
20use super::source::{ByteSpan, SourceLine};
21use super::timestamp::{TimestampSpan, parse_timestamp, scan_timestamps};
22
23static HEADLINE_RE: LazyLock<Regex> =
24    LazyLock::new(|| Regex::new(r"^(\*+)[ \t]+(.*)$").expect("headline regex"));
25static TAGS_RE: LazyLock<Regex> =
26    LazyLock::new(|| Regex::new(r"(?:^|[ \t]+)(:[\w@#%:]+:)[ \t]*$").expect("tags regex"));
27static PRIORITY_RE: LazyLock<Regex> =
28    LazyLock::new(|| Regex::new(r"^\[#([[:alnum:]])\](?:[ \t]+|$)").expect("priority regex"));
29static PROPERTY_RE: LazyLock<Regex> =
30    LazyLock::new(|| Regex::new(r"^:([[:alnum:]_-]+):(?:[ \t]*(.*))?$").expect("property regex"));
31
32#[derive(Clone, Debug)]
33pub struct PropertySpan {
34    pub key: String,
35    pub value: String,
36    pub line: ByteSpan,
37}
38
39#[derive(Clone, Debug)]
40pub struct HeadlineSpan {
41    pub item: Item,
42    pub level: usize,
43    pub line_span: ByteSpan,
44    pub subtree_span: ByteSpan,
45    pub section_span: ByteSpan,
46    pub planning_span: Option<ByteSpan>,
47    pub drawer_span: Option<ByteSpan>,
48    pub body_span: ByteSpan,
49    pub properties: Vec<PropertySpan>,
50    pub timestamps: Vec<TimestampSpan>,
51    pub digest: String,
52}
53
54#[derive(Clone, Debug)]
55struct RawHeadline {
56    level: usize,
57    line_index: usize,
58    line_span: ByteSpan,
59    section_end: usize,
60    subtree_end: usize,
61    keyword: Option<String>,
62    done: bool,
63    priority: Option<String>,
64    title: String,
65    tags: Vec<String>,
66    planning: Planning,
67    planning_span: Option<ByteSpan>,
68    drawer_span: Option<ByteSpan>,
69    properties: Vec<PropertySpan>,
70    timestamps: Vec<TimestampSpan>,
71    digest: String,
72}
73
74struct ParsedMetadata {
75    planning: Planning,
76    planning_span: Option<ByteSpan>,
77    drawer_span: Option<ByteSpan>,
78    properties: Vec<PropertySpan>,
79}
80
81#[allow(clippy::too_many_arguments)]
82pub(super) fn scan_headlines(
83    path: &Path,
84    source: &str,
85    lines: &[SourceLine],
86    todo_active: &[String],
87    todo_done: &[String],
88    file_tags: &[String],
89    file_category: &str,
90    settings: &Settings,
91) -> Result<Vec<HeadlineSpan>, AgdError> {
92    let mut raw = Vec::new();
93    for (line_index, line) in lines.iter().enumerate() {
94        let text = &source[line.content.start..line.content.end];
95        let Some(captures) = HEADLINE_RE.captures(text) else {
96            continue;
97        };
98        let stars = captures.get(1).expect("stars");
99        let content = captures.get(2).expect("headline content");
100        let (keyword, done, priority, title, tags) =
101            parse_headline_content(content.as_str(), todo_active, todo_done);
102        let ParsedMetadata {
103            planning,
104            planning_span,
105            drawer_span,
106            properties,
107        } = parse_metadata(source, lines, line_index)?;
108        let digest = headline_digest(text.as_bytes());
109        raw.push(RawHeadline {
110            level: stars.as_str().len(),
111            line_index,
112            line_span: line.content,
113            section_end: source.len(),
114            subtree_end: source.len(),
115            keyword,
116            done,
117            priority,
118            title,
119            tags,
120            planning,
121            planning_span,
122            drawer_span,
123            properties,
124            timestamps: Vec::new(),
125            digest,
126        });
127    }
128
129    for index in 0..raw.len() {
130        let next_any = raw
131            .get(index + 1)
132            .map_or(source.len(), |next| next.line_span.start);
133        raw[index].section_end = next_any;
134        raw[index].subtree_end = raw[index + 1..]
135            .iter()
136            .find(|next| next.level <= raw[index].level)
137            .map_or(source.len(), |next| next.line_span.start);
138        let timestamp_start = raw[index]
139            .planning_span
140            .map_or(raw[index].line_span.end, |span| span.end);
141        // Org lists active timestamps on the headline line itself, e.g.
142        // `* Birthday <1993-10-16 Sat +1y>`, but never the planning line.
143        let mut timestamps =
144            scan_timestamps(source, raw[index].line_span.start, raw[index].line_span.end);
145        timestamps.extend(scan_timestamps(source, timestamp_start, next_any));
146        raw[index].timestamps = timestamps;
147    }
148
149    let excluded: BTreeSet<&str> = settings
150        .tag_inheritance_exclusions
151        .iter()
152        .map(String::as_str)
153        .collect();
154    let file_path = path.to_string_lossy().into_owned();
155    let mut result = Vec::with_capacity(raw.len());
156    let mut ancestors: Vec<(usize, String, Vec<String>, String)> = Vec::new();
157    for headline in raw {
158        while ancestors
159            .last()
160            .is_some_and(|ancestor| ancestor.0 >= headline.level)
161        {
162            ancestors.pop();
163        }
164        // Org's order: #+FILETAGS, then each ancestor's tags from the top,
165        // each tag once and none the headline carries itself.
166        let mut inherited: Vec<String> = Vec::new();
167        let ancestor_tags = ancestors.iter().flat_map(|(_, _, tags, _)| tags);
168        for tag in file_tags.iter().chain(ancestor_tags) {
169            if !excluded.contains(tag.as_str())
170                && !headline.tags.contains(tag)
171                && !inherited.contains(tag)
172            {
173                inherited.push(tag.clone());
174            }
175        }
176        let own_category = headline
177            .properties
178            .iter()
179            .find(|property| property.key.eq_ignore_ascii_case("CATEGORY"))
180            .map(|property| property.value.clone());
181        let category = own_category.unwrap_or_else(|| {
182            ancestors
183                .last()
184                .map(|ancestor| ancestor.3.clone())
185                .unwrap_or_else(|| file_category.to_string())
186        });
187        let id = headline
188            .properties
189            .iter()
190            .find(|property| property.key.eq_ignore_ascii_case("ID"))
191            .map(|property| property.value.clone());
192        let mut olpath: Vec<String> = ancestors
193            .iter()
194            .map(|ancestor| ancestor.1.clone())
195            .collect();
196        olpath.push(headline.title.clone());
197        let item_ref = id.clone().unwrap_or_else(|| {
198            location_ref(path, lines[headline.line_index].number, &headline.digest)
199        });
200        let item = Item {
201            schema: ITEM_SCHEMA.to_string(),
202            item_ref,
203            id,
204            file: file_path.clone(),
205            line: lines[headline.line_index].number,
206            olpath,
207            todo: headline.keyword.clone(),
208            done: headline.done,
209            priority: headline.priority.clone(),
210            title: headline.title.clone(),
211            tags: headline.tags.clone(),
212            inherited_tags: inherited,
213            category: category.clone(),
214            planning: headline.planning.clone(),
215            checklist: None,
216        };
217        let metadata_end = headline
218            .drawer_span
219            .or(headline.planning_span)
220            .map_or(headline.line_span.end, |span| span.end);
221        let body_start = line_end_after(source, metadata_end);
222        let body_span = ByteSpan {
223            start: body_start.min(headline.section_end),
224            end: headline.section_end,
225        };
226        ancestors.push((
227            headline.level,
228            headline.title.clone(),
229            headline.tags.clone(),
230            category,
231        ));
232        let mut span = HeadlineSpan {
233            item,
234            level: headline.level,
235            line_span: headline.line_span,
236            subtree_span: ByteSpan {
237                start: headline.line_span.start,
238                end: headline.subtree_end,
239            },
240            section_span: ByteSpan {
241                start: headline.line_span.start,
242                end: headline.section_end,
243            },
244            planning_span: headline.planning_span,
245            drawer_span: headline.drawer_span,
246            body_span,
247            properties: headline.properties,
248            timestamps: headline.timestamps,
249            digest: headline.digest,
250        };
251        span.item.checklist = super::checklist::progress(source, &span);
252        result.push(span);
253    }
254    Ok(result)
255}
256
257/// The `[#X]` cookie at the start of `text`, as the parser reads a
258/// headline's priority.
259pub(crate) fn leading_priority_cookie(text: &str) -> Option<&str> {
260    let cookie = PRIORITY_RE.captures(text)?.get(0)?.as_str();
261    Some(cookie.trim_end())
262}
263
264/// Whether `tag` is one Org tag: letters, digits, `_`, `@`, `#` and `%`,
265/// the characters TAGS_RE reads in a headline's tag group.
266pub(crate) fn is_org_tag(tag: &str) -> bool {
267    !tag.is_empty()
268        && tag
269            .chars()
270            .all(|c| c.is_alphanumeric() || matches!(c, '_' | '@' | '#' | '%'))
271}
272
273/// Splits a headline's trailing tag group (`:a:b:`) off as the parser reads
274/// it: the text before it, trailing blanks removed, and the group.
275pub(crate) fn split_trailing_tags(line: &str) -> Option<(&str, &str)> {
276    let group = TAGS_RE.captures(line)?.get(1)?;
277    Some((line[..group.start()].trim_end(), group.as_str()))
278}
279
280fn parse_headline_content(
281    input: &str,
282    todo_active: &[String],
283    todo_done: &[String],
284) -> (Option<String>, bool, Option<String>, String, Vec<String>) {
285    let mut content = input.trim_end();
286    let tags = TAGS_RE
287        .captures(content)
288        .and_then(|captures| captures.get(1))
289        .map(|matched| {
290            content = content[..matched.start()].trim_end();
291            parse_colon_tags(matched.as_str())
292        })
293        .unwrap_or_default();
294
295    let first_end = content.find(char::is_whitespace).unwrap_or(content.len());
296    let first = &content[..first_end];
297    let (keyword, done) = if todo_active.iter().any(|word| word == first) {
298        content = content[first_end..].trim_start();
299        (Some(first.to_string()), false)
300    } else if todo_done.iter().any(|word| word == first) {
301        content = content[first_end..].trim_start();
302        (Some(first.to_string()), true)
303    } else {
304        (None, false)
305    };
306
307    let priority = PRIORITY_RE.captures(content).and_then(|captures| {
308        let full = captures.get(0)?;
309        let value = captures.get(1)?.as_str().to_string();
310        content = content[full.end()..].trim_start();
311        Some(value)
312    });
313    (keyword, done, priority, content.to_string(), tags)
314}
315
316fn parse_metadata(
317    source: &str,
318    lines: &[SourceLine],
319    headline_line: usize,
320) -> Result<ParsedMetadata, AgdError> {
321    let mut planning = Planning::default();
322    // Org reads planning only from the line right after the headline; a
323    // second SCHEDULED/DEADLINE line or one after a blank line is plain text
324    // with an ordinary timestamp.
325    let mut planning_span: Option<ByteSpan> = None;
326    let mut cursor = headline_line + 1;
327    if let Some(line) = lines.get(cursor) {
328        let trimmed = source[line.content.start..line.content.end].trim_start();
329        if is_planning_line(trimmed) {
330            planning_span = Some(line.full);
331            planning.scheduled = labeled_timestamp(trimmed, "SCHEDULED:");
332            planning.deadline = labeled_timestamp(trimmed, "DEADLINE:");
333            planning.closed = labeled_timestamp(trimmed, "CLOSED:");
334            cursor += 1;
335        }
336    }
337
338    let mut properties = Vec::new();
339    let mut drawer_span = None;
340    if let Some(line) = lines.get(cursor) {
341        let text = &source[line.content.start..line.content.end];
342        let trimmed = text.trim_start();
343        if trimmed == ":PROPERTIES:" {
344            let start = line.full.start;
345            cursor += 1;
346            while let Some(property_line) = lines.get(cursor) {
347                let property_trimmed =
348                    source[property_line.content.start..property_line.content.end].trim_start();
349                if property_trimmed == ":END:" {
350                    drawer_span = Some(ByteSpan {
351                        start,
352                        end: property_line.full.end,
353                    });
354                    break;
355                }
356                if let Some(captures) = PROPERTY_RE.captures(property_trimmed) {
357                    let key = captures.get(1).expect("property key");
358                    let value = captures.get(2);
359                    properties.push(PropertySpan {
360                        key: key.as_str().to_string(),
361                        value: value.map_or("", |matched| matched.as_str()).to_string(),
362                        line: property_line.full,
363                    });
364                }
365                cursor += 1;
366            }
367            if drawer_span.is_none() {
368                return Err(AgdError::new(
369                    ErrorCode::ParseFailed,
370                    format!(
371                        "unterminated property drawer after line {}",
372                        headline_line + 1
373                    ),
374                ));
375            }
376        }
377    }
378    Ok(ParsedMetadata {
379        planning,
380        planning_span,
381        drawer_span,
382        properties,
383    })
384}
385
386pub(super) fn parse_colon_tags(value: &str) -> Vec<String> {
387    value
388        .trim()
389        .trim_matches(':')
390        .split(':')
391        .filter(|tag| !tag.is_empty())
392        .map(str::to_string)
393        .collect()
394}
395
396// A planning line starts with one of the keywords; `: SCHEDULED: <…>` is
397// fixed-width text, not planning.
398fn is_planning_line(line: &str) -> bool {
399    let line = line.trim_start();
400    ["SCHEDULED:", "DEADLINE:", "CLOSED:"]
401        .iter()
402        .any(|label| line.starts_with(label))
403}
404
405fn labeled_timestamp(line: &str, label: &str) -> Option<PlanningTimestamp> {
406    let after = line.split_once(label)?.1;
407    parse_timestamp(after)
408}
409
410/// The ref of a heading without a unique ID: `file:line#sha256:digest`.
411pub(super) fn location_ref(path: &Path, line: usize, digest: &str) -> String {
412    format!("{}:{line}#sha256:{digest}", path.display())
413}
414
415/// The first 16 bytes of the headline line's SHA-256, as a ref's digest.
416fn headline_digest(bytes: &[u8]) -> String {
417    sha256_hex(bytes)[..32].to_string()
418}
419
420/// SHA-256 as lowercase hex, for refs, journal hashes and lock names.
421pub(crate) fn sha256_hex(bytes: &[u8]) -> String {
422    Sha256::digest(bytes)
423        .iter()
424        .map(|byte| format!("{byte:02x}"))
425        .collect()
426}
427
428fn line_end_after(source: &str, offset: usize) -> usize {
429    if offset >= source.len() {
430        return source.len();
431    }
432    if source.as_bytes().get(offset) == Some(&b'\n') {
433        offset + 1
434    } else {
435        offset
436    }
437}