Skip to main content

agd_core/document/
timestamp.rs

1//! Org timestamps in text: `parse_timestamp` reads one (date, time range,
2//! repeater, warning and raw text), and `scan_timestamps` finds the ones Org
3//! would list in a span of source.
4//!
5//! Timestamps in verbatim blocks (src, example, export, comment), fixed-width
6//! lines and link targets are skipped, as Org skips them.
7
8use std::sync::LazyLock;
9
10use regex::Regex;
11
12use crate::PlanningTimestamp;
13
14use super::source::ByteSpan;
15
16static LINK_TARGET_RE: LazyLock<Regex> =
17    LazyLock::new(|| Regex::new(r"\[\[[^\]\n]*\]").expect("link target regex"));
18static TIMESTAMP_RE: LazyLock<Regex> = LazyLock::new(|| {
19    Regex::new(
20        r"[<\[](\d{4}-\d{2}-\d{2})(?:[ \t]+[^\s\d+\->\]]+)?(?:[ \t]+(\d{1,2}:\d{2})(?:-(\d{1,2}:\d{2}))?)?([^>\]]*)[>\]]",
21    )
22    .expect("timestamp regex")
23});
24
25#[derive(Clone, Debug)]
26pub struct TimestampSpan {
27    pub timestamp: PlanningTimestamp,
28    pub span: ByteSpan,
29    pub active: bool,
30}
31
32pub fn parse_timestamp(input: &str) -> Option<PlanningTimestamp> {
33    let captures = TIMESTAMP_RE.captures(input)?;
34    let suffix = captures.get(4).map_or("", |matched| matched.as_str());
35    let repeater = suffix
36        .split_whitespace()
37        .find(|token| token.starts_with('+') || token.starts_with(".+"))
38        .map(str::to_string);
39    let warning = suffix
40        .split_whitespace()
41        .find(|token| token.starts_with('-'))
42        .map(str::to_string);
43    Some(PlanningTimestamp {
44        date: captures.get(1)?.as_str().to_string(),
45        time: captures.get(2).map(|matched| clock(matched.as_str())),
46        end_time: captures.get(3).map(|matched| clock(matched.as_str())),
47        repeater,
48        warning,
49        raw: captures.get(0).map(|matched| matched.as_str().to_string()),
50    })
51}
52
53/// A timestamp's time as the model gives every time: `HH:MM`, so `9:30` is
54/// `09:30`. The file's own text stays in `raw`.
55fn clock(time: &str) -> String {
56    format!("{time:0>5}")
57}
58
59// Org ignores timestamps in src/example/export/comment blocks, in
60// fixed-width lines (`: text`) and inside link targets.
61pub(super) fn scan_timestamps(source: &str, start: usize, end: usize) -> Vec<TimestampSpan> {
62    if start >= end || end > source.len() {
63        return Vec::new();
64    }
65    let mut found = Vec::new();
66    let mut in_verbatim_block = false;
67    let mut line_start = start;
68    for line in source[start..end].split_inclusive('\n') {
69        let offset = line_start;
70        line_start += line.len();
71        let trimmed = line.trim();
72        let lower = trimmed.to_ascii_lowercase();
73        if in_verbatim_block {
74            in_verbatim_block = !lower.starts_with("#+end_");
75            continue;
76        }
77        if [
78            "#+begin_src",
79            "#+begin_example",
80            "#+begin_export",
81            "#+begin_comment",
82        ]
83        .iter()
84        .any(|opening| lower.starts_with(opening))
85        {
86            in_verbatim_block = true;
87            continue;
88        }
89        if trimmed == ":" || trimmed.starts_with(": ") {
90            continue;
91        }
92        let masked = LINK_TARGET_RE.replace_all(line, |captures: &regex::Captures| {
93            " ".repeat(captures[0].len())
94        });
95        for captures in TIMESTAMP_RE.captures_iter(&masked) {
96            let Some(matched) = captures.get(0) else {
97                continue;
98            };
99            let Some(timestamp) = parse_timestamp(matched.as_str()) else {
100                continue;
101            };
102            found.push(TimestampSpan {
103                timestamp,
104                span: ByteSpan {
105                    start: offset + matched.start(),
106                    end: offset + matched.end(),
107                },
108                active: matched.as_str().starts_with('<'),
109            });
110        }
111    }
112    found
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    #[test]
120    fn times_read_as_hh_mm_and_raw_keeps_the_text() {
121        let timestamp = parse_timestamp("<2026-10-13 Tue 9:30-10:15 +1w>").unwrap();
122        assert_eq!(timestamp.time.as_deref(), Some("09:30"));
123        assert_eq!(timestamp.end_time.as_deref(), Some("10:15"));
124        assert_eq!(
125            timestamp.raw.as_deref(),
126            Some("<2026-10-13 Tue 9:30-10:15 +1w>")
127        );
128    }
129}