Skip to main content

markdown_org_extract/timestamp/
parser.rs

1use chrono::NaiveDate;
2use regex::Regex;
3use std::borrow::Cow;
4use std::sync::LazyLock;
5
6use super::repeater::{parse_repeater, Repeater};
7use super::weekdays::normalize_weekdays;
8use crate::regex_limits::{compile_bounded, TS_BODY_MAX};
9
10// Main bracket regexes (one per family, ADR-0014): anchor the
11// `<YYYY-MM-DD ...>` (active) or `[YYYY-MM-DD ...]` (inactive) form and
12// capture only the date. Body content (weekday, time, repeater, warning
13// cookie) is left flexible and scanned separately so that order of
14// repeater vs warning follows upstream Org-mode semantics
15// (`org-get-wdays` in lisp/org.el just searches the whole timestamp
16// string for `-N[hdwmy]`, irrespective of where the repeater sits).
17// Date is mandatory; everything else is optional and order-independent.
18//
19// Range timestamps like `<...>--<...>` or `[...]--[...]` fall through
20// these regexes naturally: each regex matches the first bracket and
21// captures the start date, which is what `parse_org_timestamp` returns
22// for ranges anyway. The `--?-?` separator and the trailing bracket are
23// not consumed here; other code paths (e.g., the end-time extraction in
24// `extract.rs`) handle ranges with their own regexes. Paired alternation
25// (no `[<\[]...[>\]]` shortcut) keeps mixed pairs `<...]` / `[...>` from
26// matching by construction.
27static SINGLE_ANGLE_RE: LazyLock<Regex> = LazyLock::new(|| {
28    // The body quantifier shares `TS_BODY_MAX` with the extractor patterns
29    // in `extract.rs`. Using the same bound on both sides guarantees that
30    // every timestamp the extractor accepts also parses here: a literal
31    // `80` once let bodies in the 81..=256 range pass extraction yet fail
32    // to parse, silently dropping the task from every agenda bucket (F2 in
33    // the 2026-05-25 logic review). `TS_BODY_MAX` is a defense-in-depth
34    // ceiling, not a semantic limit; realistic bodies (full weekday name +
35    // HH:MM-HH:MM range + repeater + warning cookie) stay well under it.
36    // The square-bracket variant uses `[^\[\]]{0,TS_BODY_MAX}` identically.
37    compile_bounded(&format!(
38        r"<(\d{{4}}-\d{{2}}-\d{{2}})[^<>]{{0,{TS_BODY_MAX}}}>"
39    ))
40});
41
42static SINGLE_SQUARE_RE: LazyLock<Regex> = LazyLock::new(|| {
43    compile_bounded(&format!(
44        r"\[(\d{{4}}-\d{{2}}-\d{{2}})[^\[\]]{{0,{TS_BODY_MAX}}}\]"
45    ))
46});
47
48// Scan the bracket body for a repeater token. Matches upstream Org-mode
49// `org-repeater-regexp-base` shape: a `+`, `++`, or `.+` prefix, followed
50// by a positive integer, followed by a unit (d/w/m/y/h or `wd` for the
51// project's workday extension).
52static REPEATER_BODY_RE: LazyLock<Regex> =
53    LazyLock::new(|| compile_bounded(r"([.+]+\d+(?:wd|[dwmyh]))"));
54
55// Scan the bracket body for a warning-period cookie `-N[hdwmy]`.
56//
57// Upstream Emacs Org-mode `org-get-wdays` (lisp/org.el L14937) uses
58// `-\([0-9]+\)\([hdwmy]\)\(\'\|>\| \)`: digits, a unit char, then a
59// terminator that is end-of-string, `>`, or a literal space. This
60// project's pattern keeps the same terminator idea with two
61// deliberate divergences from upstream, recorded in ADR-0018
62// ("Warning-cookie boundary divergence from upstream"):
63//   1. A leading `\s` is *required* before `-`. Upstream relies on the
64//      unit char + terminator alone to avoid matching the date's own
65//      `-MM` / `-DD` runs (a date component is never followed by
66//      `[hdwmy]`). Requiring a separator is stricter and fail-closed:
67//      a cookie glued to preceding text (`...d-3d`) is ignored rather
68//      than guessed at.
69//   2. The terminator class is `[\s>\]]|$` instead of upstream's
70//      ` |>|\'`. It adds `]` so an inactive `[... -3d]` cookie reads
71//      the same as the active `<... -3d>` form, and uses `\s` (any
72//      whitespace) rather than a single literal space.
73// Consequences of (1)+(2): `-3day` is not a cookie (`a` not in the
74// terminator class), and a pathological double cookie `-3d-2d` matches
75// nothing here (the `-` after `3d` is not a terminator, and `-2d` has
76// no leading `\s`), whereas upstream would extract the trailing `-2d`.
77// These bodies are not produced by Emacs; the fail-closed reading is
78// pinned by `warning_cookie_requires_separator_and_terminator`.
79static WARNING_BODY_RE: LazyLock<Regex> =
80    LazyLock::new(|| compile_bounded(r"\s-(\d+)([hdwmy])(?:[\s>\]]|$)"));
81
82/// Result of parsing a single org-mode timestamp string.
83#[derive(Debug, Clone)]
84pub struct ParsedTimestamp {
85    /// The base date encoded in the timestamp (start date for ranges).
86    pub date: NaiveDate,
87    /// Optional repeater (`+1d`, `.+2w`, ...).
88    pub repeater: Option<Repeater>,
89    /// Optional per-task warning lead time (`-Nd`, `-Nw`, `-Nm`, `-Ny`,
90    /// `-Nh`) converted to whole days using upstream Org-mode's factors
91    /// (see `org-get-wdays` in `lisp/org.el`). When set, it overrides the
92    /// global `DEADLINE_WARNING_DAYS` for the corresponding DEADLINE.
93    pub warning_days: Option<i64>,
94    /// Bracket form: `true` for active `<...>`, `false` for inactive
95    /// `[...]`. See ADR-0014 for which keywords accept which forms and
96    /// for the agenda invariant (inactive timestamps never feed agenda).
97    pub active: bool,
98}
99
100/// Convert a warning cookie's value/unit pair into whole days, mirroring
101/// upstream `org-get-wdays`: `floor(N * factor)` with day-equivalents
102/// `d=1`, `w=7`, `m=30.4`, `y=365.25`, `h=1/24`. Returns `None` for any
103/// unrecognised unit, which keeps the parser fail-closed.
104fn warning_cookie_to_days(value: i64, unit: &str) -> Option<i64> {
105    let factor = match unit {
106        "d" => 1.0,
107        "w" => 7.0,
108        "m" => 30.4,
109        "y" => 365.25,
110        "h" => 1.0 / 24.0,
111        _ => return None,
112    };
113    Some((value as f64 * factor).floor() as i64)
114}
115
116/// Parse a single org-mode timestamp like `<2024-12-05 Thu 10:00 +1d>` or
117/// `<2024-12-05>--<2024-12-06>`, optionally normalizing localized weekday names.
118///
119/// Repeater and warning-period cookies are extracted by independent passes
120/// on the bracket body, so they may appear in either order
121/// (`<... +1y -3d>` or `<... -3d +1y>`), matching upstream Org-mode's
122/// position-agnostic handling in `org-get-wdays`.
123pub fn parse_org_timestamp(ts: &str, mappings: Option<&[(&str, &str)]>) -> Option<ParsedTimestamp> {
124    let ts = if let Some(m) = mappings {
125        normalize_weekdays(ts, m)
126    } else {
127        Cow::Borrowed(ts)
128    };
129
130    // Try both bracket families and take whichever starts first. Both
131    // regexes anchor on a date pattern, so the earlier match position is
132    // the one a human reader would also pick.
133    let angle = SINGLE_ANGLE_RE.captures(&ts);
134    let square = SINGLE_SQUARE_RE.captures(&ts);
135    let caps = match (&angle, &square) {
136        (Some(a), Some(s)) => {
137            if a.get(0).unwrap().start() <= s.get(0).unwrap().start() {
138                angle.as_ref().unwrap()
139            } else {
140                square.as_ref().unwrap()
141            }
142        }
143        (Some(_), None) => angle.as_ref().unwrap(),
144        (None, Some(_)) => square.as_ref().unwrap(),
145        (None, None) => return None,
146    };
147    let date = NaiveDate::parse_from_str(&caps[1], "%Y-%m-%d").ok()?;
148    let bracket = caps.get(0).map(|m| m.as_str()).unwrap_or("");
149
150    let repeater = REPEATER_BODY_RE
151        .captures(bracket)
152        .and_then(|c| parse_repeater(c.get(1)?.as_str()));
153
154    let warning_days = WARNING_BODY_RE.captures(bracket).and_then(|c| {
155        let value: i64 = c.get(1)?.as_str().parse().ok()?;
156        warning_cookie_to_days(value, c.get(2)?.as_str())
157    });
158
159    // `<...>` ⇒ active, `[...]` ⇒ inactive. The opening byte is the
160    // single source of truth because the two regex families never
161    // produce mixed pairs.
162    let active = bracket.starts_with('<');
163
164    Some(ParsedTimestamp {
165        date,
166        repeater,
167        warning_days,
168        active,
169    })
170}
171
172/// Weekday token straight after the date: a run of letters, so a time
173/// (`10:00`) or a repeater (`+1w`) is not mistaken for one. Anchored, because
174/// only the token in that position is the weekday.
175static WEEKDAY_AFTER_DATE_RE: LazyLock<Regex> =
176    LazyLock::new(|| compile_bounded(r"^[ \t]+(\p{L}+)"));
177
178/// A timestamp located token by token, for a caller that rewrites its date.
179///
180/// The ranges are byte offsets into the text the timestamp was found in, not
181/// into the timestamp itself, so an editor can splice a new date into the line
182/// it already holds. Everything the timestamp carries besides the date — the
183/// time, the repeater, the warning cookie — stays where it is, which is what
184/// makes moving a date a one-token edit.
185#[derive(Debug, Clone, PartialEq)]
186pub struct TimestampParts {
187    /// The whole timestamp, brackets included.
188    pub whole: std::ops::Range<usize>,
189    /// The `YYYY-MM-DD` date.
190    pub date: std::ops::Range<usize>,
191    /// The date that range parsed to.
192    pub value: NaiveDate,
193    /// The weekday token, when the timestamp carries one. Reported as
194    /// written, in whatever language and length: a caller replacing the date
195    /// is expected to keep both.
196    pub weekday: Option<std::ops::Range<usize>>,
197    /// The repeater, when the timestamp carries one.
198    pub repeater: Option<Repeater>,
199    /// Bracket form: `true` for active `<...>`, `false` for inactive `[...]`.
200    pub active: bool,
201}
202
203/// Locate the first timestamp in `text`, or return `None` when there is none.
204///
205/// The counterpart of [`parse_org_timestamp`] for editing rather than reading:
206/// same grammar, same bracket families, but reporting where each token sits.
207/// Weekday names are matched as written — no normalisation pass — because a
208/// caller rewriting the date has to put back a weekday in the same language.
209///
210/// ```
211/// # use markdown_org_extract::parse_timestamp_parts;
212/// let line = "`SCHEDULED: <2026-07-28 Tue 10:00 +1w>`";
213/// let parts = parse_timestamp_parts(line).expect("a timestamp");
214/// assert_eq!(&line[parts.date], "2026-07-28");
215/// ```
216pub fn parse_timestamp_parts(text: &str) -> Option<TimestampParts> {
217    let angle = SINGLE_ANGLE_RE.captures(text);
218    let square = SINGLE_SQUARE_RE.captures(text);
219    // Whichever bracket family starts first, the way `parse_org_timestamp`
220    // picks it.
221    let caps = match (&angle, &square) {
222        (Some(a), Some(s)) => {
223            let (a_start, s_start) = (
224                a.get(0).expect("group 0 is Some").start(),
225                s.get(0).expect("group 0 is Some").start(),
226            );
227            if a_start <= s_start {
228                angle.as_ref()?
229            } else {
230                square.as_ref()?
231            }
232        }
233        (Some(_), None) => angle.as_ref()?,
234        (None, Some(_)) => square.as_ref()?,
235        (None, None) => return None,
236    };
237
238    let whole = caps.get(0).expect("group 0 is Some");
239    let date = caps.get(1).expect("group 1 is Some");
240    let value = NaiveDate::parse_from_str(date.as_str(), "%Y-%m-%d").ok()?;
241
242    let body = whole.as_str();
243    let repeater = REPEATER_BODY_RE
244        .captures(body)
245        .and_then(|c| parse_repeater(c.get(1)?.as_str()));
246
247    let weekday = WEEKDAY_AFTER_DATE_RE
248        .captures(&text[date.end()..whole.end()])
249        .map(|c| {
250            let token = c.get(1).expect("group 1 is Some");
251            date.end() + token.start()..date.end() + token.end()
252        });
253
254    Some(TimestampParts {
255        whole: whole.start()..whole.end(),
256        date: date.start()..date.end(),
257        value,
258        weekday,
259        repeater,
260        active: body.starts_with('<'),
261    })
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    #[test]
269    fn test_parse_timestamp_with_workday_repeater() {
270        let ts = "<2025-12-05 Thu +1wd>";
271        let parsed = parse_org_timestamp(ts, None).unwrap();
272        assert_eq!(parsed.date, NaiveDate::from_ymd_opt(2025, 12, 5).unwrap());
273        assert!(parsed.repeater.is_some());
274        let repeater = parsed.repeater.unwrap();
275        assert_eq!(repeater.value, 1);
276        assert_eq!(repeater.unit, super::super::repeater::RepeaterUnit::Workday);
277    }
278
279    #[test]
280    fn test_parse_timestamp_with_workday_repeater_multiple() {
281        let ts = "<2025-12-09 Mon +2wd>";
282        let parsed = parse_org_timestamp(ts, None).unwrap();
283        let repeater = parsed.repeater.unwrap();
284        assert_eq!(repeater.value, 2);
285        assert_eq!(repeater.unit, super::super::repeater::RepeaterUnit::Workday);
286    }
287
288    #[test]
289    fn test_parse_timestamp_with_regular_repeater() {
290        let ts = "<2025-12-05 Thu +1d>";
291        let parsed = parse_org_timestamp(ts, None).unwrap();
292        let repeater = parsed.repeater.unwrap();
293        assert_eq!(repeater.unit, super::super::repeater::RepeaterUnit::Day);
294    }
295
296    #[test]
297    fn range_separator_accepts_one_two_three_dashes() {
298        // Emacs' org-tr-regexp uses `--?-?`, i.e. one, two, or three dashes
299        // between the bracketed values. parse_org_timestamp must accept all
300        // three; the start date alone is surfaced (end-date support is a
301        // separate concern, documented in README + ADR-0002).
302        for sep in ["-", "--", "---"] {
303            let ts = format!("<2025-12-05 Thu>{sep}<2025-12-06 Fri>");
304            let parsed = parse_org_timestamp(&ts, None)
305                .unwrap_or_else(|| panic!("must parse range with {sep:?} as separator"));
306            assert_eq!(
307                parsed.date,
308                NaiveDate::from_ymd_opt(2025, 12, 5).unwrap(),
309                "start date must be the first bracket for separator {sep:?}"
310            );
311        }
312    }
313
314    #[test]
315    fn parse_org_timestamp_accepts_body_up_to_extractor_limit() {
316        // Regression for F2 (2026-05-25 logic review). The extractor in
317        // extract.rs bounds the timestamp body with TS_BODY_MAX (256), while
318        // parse_org_timestamp used a literal 80. A body longer than 80 but
319        // within TS_BODY_MAX was accepted on extraction yet failed to parse
320        // here, so the task silently dropped out of every agenda bucket
321        // (it stayed in `--tasks`). Both sides now share TS_BODY_MAX, so
322        // "what passed extraction passes parsing" holds.
323        let filler = " ".repeat(120); // after the date: > 80, < TS_BODY_MAX
324        let ts = format!("<2024-12-09 Mon{filler}+1m>");
325        assert!(
326            ts.len() > 80 + 12,
327            "test fixture must exceed the old 80-char body bound"
328        );
329        let parsed =
330            parse_org_timestamp(&ts, None).expect("body within TS_BODY_MAX must still parse");
331        assert_eq!(parsed.date, NaiveDate::from_ymd_opt(2024, 12, 9).unwrap());
332        assert!(
333            parsed.repeater.is_some(),
334            "a repeater after a long body must still be found"
335        );
336        assert!(parsed.active);
337    }
338
339    // Warning-period cookie semantics mirror upstream Emacs Org-mode's
340    // `org-get-wdays` (lisp/org.el L14937-14943): `-N<unit>` where unit is
341    // one of h/d/w/m/y, converted to days as floor(N * factor) with
342    // factors d=1, w=7, m=30.4, y=365.25, h=1/24. The presence of the
343    // cookie on a DEADLINE overrides the global `DEADLINE_WARNING_DAYS`
344    // for that one task.
345
346    #[test]
347    fn parse_without_warning_period_yields_none() {
348        let parsed = parse_org_timestamp("<2025-12-10 Wed>", None).unwrap();
349        assert_eq!(parsed.warning_days, None);
350    }
351
352    #[test]
353    fn parse_warning_period_days() {
354        let parsed = parse_org_timestamp("<2025-12-10 Wed -3d>", None).unwrap();
355        assert_eq!(parsed.warning_days, Some(3));
356    }
357
358    #[test]
359    fn parse_warning_period_weeks() {
360        // 1w = 7d (floor(1 * 7))
361        let parsed = parse_org_timestamp("<2025-12-10 Wed -2w>", None).unwrap();
362        assert_eq!(parsed.warning_days, Some(14));
363    }
364
365    #[test]
366    fn parse_warning_period_months_floored() {
367        // 1m = floor(30.4) = 30
368        let parsed = parse_org_timestamp("<2025-12-10 Wed -1m>", None).unwrap();
369        assert_eq!(parsed.warning_days, Some(30));
370    }
371
372    #[test]
373    fn parse_warning_period_years_floored() {
374        // 1y = floor(365.25) = 365
375        let parsed = parse_org_timestamp("<2025-12-10 Wed -1y>", None).unwrap();
376        assert_eq!(parsed.warning_days, Some(365));
377    }
378
379    #[test]
380    fn parse_warning_period_hours_floored_to_zero_for_small_n() {
381        // 1h = floor(1/24) = 0. Edge case, but matches upstream's
382        // floor-semantics so the agenda code can treat 0 as "show only
383        // on the day itself".
384        let parsed = parse_org_timestamp("<2025-12-10 Wed -1h>", None).unwrap();
385        assert_eq!(parsed.warning_days, Some(0));
386    }
387
388    #[test]
389    fn parse_warning_period_with_repeater_in_either_order() {
390        // Both orderings must be recognised: upstream `org-get-wdays`
391        // scans the full bracket body without caring whether the repeater
392        // sits before or after the warning cookie.
393        let with_repeater_first = parse_org_timestamp("<2025-12-10 Wed +1d -3d>", None).unwrap();
394        assert_eq!(with_repeater_first.warning_days, Some(3));
395        assert!(with_repeater_first.repeater.is_some());
396
397        let with_warning_first = parse_org_timestamp("<2025-12-10 Wed -3d +1d>", None).unwrap();
398        assert_eq!(with_warning_first.warning_days, Some(3));
399        assert!(with_warning_first.repeater.is_some());
400    }
401
402    #[test]
403    fn warning_cookie_requires_separator_and_terminator() {
404        // Pins the two deliberate divergences from upstream `org-get-wdays`
405        // documented on WARNING_BODY_RE (ADR-0018, F5 in the 2026-05-25
406        // logic review). The parser is fail-closed: a cookie is read only
407        // when separated by whitespace and closed by whitespace / `>` /
408        // `]` / end-of-string.
409
410        // A well-formed, separated cookie is read (baseline).
411        assert_eq!(
412            parse_org_timestamp("<2025-12-10 Wed -3d>", None)
413                .unwrap()
414                .warning_days,
415            Some(3)
416        );
417
418        // No leading separator: `-2d` is glued to the preceding `d`, so it
419        // is NOT a cookie here. Upstream would extract the trailing `-2d`;
420        // we deliberately read nothing.
421        assert_eq!(
422            parse_org_timestamp("<2025-12-10 Wed -3d-2d>", None)
423                .unwrap()
424                .warning_days,
425            None,
426            "double cookie -3d-2d must be fail-closed (no leading separator on -2d)"
427        );
428
429        // Unit char not followed by a terminator: `-3day` is a word, not a
430        // cookie, because `a` is outside the terminator class.
431        assert_eq!(
432            parse_org_timestamp("<2025-12-10 Wed -3day>", None)
433                .unwrap()
434                .warning_days,
435            None,
436            "`-3day` is not a warning cookie"
437        );
438    }
439
440    // ADR-0014: bracket form is reported via `active`. `<...>` = active,
441    // `[...]` = inactive. Both forms parse the same internal fields
442    // (date, repeater, warning days); the difference is only in the
443    // bracket and in how downstream consumers (e.g., agenda) treat the
444    // value (inactive never feeds agenda).
445    #[test]
446    fn parse_org_timestamp_marks_angle_bracket_as_active() {
447        let parsed = parse_org_timestamp("<2025-12-10 Wed>", None).unwrap();
448        assert!(parsed.active, "<...> timestamp must be active");
449    }
450
451    #[test]
452    fn parse_org_timestamp_marks_square_bracket_as_inactive() {
453        let parsed = parse_org_timestamp("[2025-12-10 Wed]", None).unwrap();
454        assert!(!parsed.active, "[...] timestamp must be inactive");
455        assert_eq!(parsed.date, NaiveDate::from_ymd_opt(2025, 12, 10).unwrap());
456    }
457
458    #[test]
459    fn parse_inactive_timestamp_with_repeater() {
460        // Repeater grammar is identical inside `[...]` — REPEATER_BODY_RE
461        // scans bracket-agnostic body text.
462        let parsed = parse_org_timestamp("[2025-12-05 Thu +1d]", None).unwrap();
463        let repeater = parsed.repeater.unwrap();
464        assert_eq!(repeater.value, 1);
465        assert_eq!(repeater.unit, super::super::repeater::RepeaterUnit::Day);
466        assert!(!parsed.active);
467    }
468
469    #[test]
470    fn parse_inactive_timestamp_with_warning_period() {
471        // WARNING_BODY_RE must accept `]` as a terminator the same way it
472        // accepts `>` and whitespace for active timestamps. Upstream Org-mode
473        // never emits a warning cookie inside `[...]` (org-expiry / org-closed
474        // do not carry warning days), but the parser is symmetric so an
475        // author who chose to write `[... -3d]` is not silently ignored.
476        let parsed = parse_org_timestamp("[2025-12-10 Wed -3d]", None).unwrap();
477        assert_eq!(parsed.warning_days, Some(3));
478        assert!(!parsed.active);
479    }
480
481    #[test]
482    fn parse_inactive_timestamp_normalizes_localized_weekday() {
483        // Weekday-normalization runs on the whole string and should not
484        // care about bracket form. The `Cow::Borrowed` fast path applies
485        // when no mapping changes the input, exactly as for `<...>`.
486        let mappings: &[(&str, &str)] = &[("Чт", "Thu")];
487        let parsed = parse_org_timestamp("[2025-12-05 Чт]", Some(mappings)).unwrap();
488        assert_eq!(parsed.date, NaiveDate::from_ymd_opt(2025, 12, 5).unwrap());
489        assert!(!parsed.active);
490    }
491
492    #[test]
493    fn parse_org_timestamp_prefers_first_bracket_form() {
494        // If a line contains a square bracket before an angle bracket and
495        // both look like valid timestamps, the first match wins. This
496        // mirrors the existing behaviour for `<...>foo<...>` (first one
497        // is taken). The test pins the precedence so a future regex
498        // refactor cannot silently flip it.
499        let parsed = parse_org_timestamp("[2025-12-05 Thu] <2025-12-06 Fri>", None).unwrap();
500        assert_eq!(parsed.date, NaiveDate::from_ymd_opt(2025, 12, 5).unwrap());
501        assert!(!parsed.active);
502    }
503}