1use 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 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 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
257pub(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
264pub(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
273pub(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 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
396fn 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
410pub(super) fn location_ref(path: &Path, line: usize, digest: &str) -> String {
412 format!("{}:{line}#sha256:{digest}", path.display())
413}
414
415fn headline_digest(bytes: &[u8]) -> String {
417 sha256_hex(bytes)[..32].to_string()
418}
419
420pub(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}