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}