1use crate::model::TODO_KEYWORDS;
12
13pub const PLANNING_KEYS: &[&str] = &["CLOSED", "SCHEDULED", "DEADLINE"];
15
16pub fn is_org_tag_char(c: char) -> bool {
18 c.is_alphanumeric() || matches!(c, '_' | '@' | '#' | '%')
19}
20
21pub fn is_headline(line: &str) -> bool {
27 let stars = line.len() - line.trim_start_matches('*').len();
28 stars > 0 && line[stars..].starts_with(' ')
29}
30
31pub fn is_top_level_headline(line: &str) -> bool {
33 line.starts_with("* ")
34}
35
36pub fn opens_a_drawer(trimmed: &str) -> bool {
38 trimmed.len() > 2
39 && trimmed.starts_with(':')
40 && trimmed.ends_with(':')
41 && !trimmed.eq_ignore_ascii_case(":END:")
42 && !trimmed[1..trimmed.len() - 1].contains(char::is_whitespace)
43}
44
45#[derive(Debug, Default, Clone)]
51pub struct BlockNest {
52 depth: usize,
53}
54
55impl BlockNest {
56 pub fn new() -> Self {
58 Self::default()
59 }
60
61 pub fn inside(&self) -> bool {
63 self.depth > 0
64 }
65
66 pub fn observe(&mut self, line: &str) -> bool {
69 let trimmed = line.trim_start();
70 if is_block_end(trimmed) {
71 self.depth = self.depth.saturating_sub(1);
72 return true;
73 }
74 if is_block_begin(trimmed) {
75 self.depth += 1;
76 return true;
77 }
78 self.depth > 0
79 }
80}
81
82#[derive(Debug, Default, Clone)]
89pub struct OrgScan {
90 blocks: BlockNest,
91 results: ResultsState,
92}
93
94#[derive(Debug, Clone, Default, PartialEq, Eq)]
95enum ResultsState {
96 #[default]
97 Out,
98 Awaiting,
100 ViaBlock,
102 Drawer,
103 Table,
104 FixedWidth,
105 List,
106 Headline {
107 stars: usize,
108 },
109}
110
111impl OrgScan {
112 pub fn new() -> Self {
114 Self::default()
115 }
116
117 pub fn inside(&self) -> bool {
119 self.blocks.inside() || !matches!(self.results, ResultsState::Out)
120 }
121
122 pub fn observe(&mut self, line: &str) -> bool {
125 let trimmed = line.trim_start();
126
127 if self.blocks.inside() || is_block_end(trimmed) {
128 let in_block = self.blocks.observe(line);
129 if !self.blocks.inside() && matches!(self.results, ResultsState::ViaBlock) {
130 self.results = ResultsState::Out;
131 }
132 return in_block || matches!(self.results, ResultsState::ViaBlock);
133 }
134
135 if is_results_keyword(line.trim()) {
136 self.results = ResultsState::Awaiting;
137 return true;
138 }
139
140 if is_block_begin(trimmed) {
141 if matches!(self.results, ResultsState::Awaiting) {
142 self.results = ResultsState::ViaBlock;
143 }
144 return self.blocks.observe(line);
145 }
146
147 match self.results {
148 ResultsState::Out => false,
149 ResultsState::ViaBlock => {
150 self.results = ResultsState::Out;
151 false
152 }
153 ResultsState::Awaiting => {
154 if line.trim().is_empty() {
155 return true;
156 }
157 self.start_result_element(line)
158 }
159 ResultsState::Drawer => {
160 if line.trim().eq_ignore_ascii_case(":END:") {
161 self.results = ResultsState::Out;
162 }
163 true
164 }
165 ResultsState::Table => {
166 if is_org_table_line(line.trim()) {
167 true
168 } else {
169 self.results = ResultsState::Out;
170 self.observe(line)
171 }
172 }
173 ResultsState::FixedWidth => {
174 if is_fixed_width_line(line) {
175 true
176 } else {
177 self.results = ResultsState::Out;
178 self.observe(line)
179 }
180 }
181 ResultsState::List => {
182 if line.trim().is_empty() {
183 self.results = ResultsState::Out;
184 return true;
185 }
186 if is_org_list_line(line) || is_list_continuation(line) {
187 true
188 } else {
189 self.results = ResultsState::Out;
190 self.observe(line)
191 }
192 }
193 ResultsState::Headline { stars } => {
194 if is_headline(line) {
195 let n = headline_stars(line);
196 if n > 0 && n <= stars {
197 self.results = ResultsState::Out;
198 return self.observe(line);
199 }
200 }
201 true
202 }
203 }
204 }
205
206 fn start_result_element(&mut self, line: &str) -> bool {
207 let trimmed = line.trim();
208 if opens_a_drawer(trimmed) {
209 self.results = ResultsState::Drawer;
210 return true;
211 }
212 if is_org_table_line(trimmed) {
213 self.results = ResultsState::Table;
214 return true;
215 }
216 if is_fixed_width_line(line) {
217 self.results = ResultsState::FixedWidth;
218 return true;
219 }
220 if is_org_list_line(line) {
221 self.results = ResultsState::List;
222 return true;
223 }
224 if is_headline(line) {
225 self.results = ResultsState::Headline {
226 stars: headline_stars(line),
227 };
228 return true;
229 }
230 self.results = ResultsState::Out;
233 true
234 }
235}
236
237pub fn is_results_keyword(trimmed: &str) -> bool {
239 let Some(rest) = strip_hash_plus(trimmed.trim()) else {
240 return false;
241 };
242 let Some(after) =
243 strip_keyword_prefix(rest, "RESULTS").or_else(|| strip_keyword_prefix(rest, "RESULT"))
244 else {
245 return false;
246 };
247 let after = after.trim_start();
248 if after.starts_with(':') {
249 return true;
250 }
251 if after.starts_with('[') {
252 return after.contains(':');
253 }
254 false
255}
256
257pub fn is_babel_call(trimmed: &str) -> bool {
259 let Some(rest) = strip_hash_plus(trimmed.trim()) else {
260 return false;
261 };
262 strip_keyword_prefix(rest, "CALL").is_some_and(|after| after.starts_with(':'))
263}
264
265pub fn is_affiliated_keyword(trimmed: &str) -> bool {
267 let Some(rest) = strip_hash_plus(trimmed.trim()) else {
268 return false;
269 };
270 let Some((key, _)) = rest.split_once(':') else {
271 return false;
272 };
273 let key = key.trim();
274 if starts_ignore_ascii(key, "ATTR_") {
275 return true;
276 }
277 matches!(
278 key.to_ascii_uppercase().as_str(),
279 "CAPTION"
280 | "DATA"
281 | "HEADER"
282 | "HEADERS"
283 | "LABEL"
284 | "NAME"
285 | "PLOT"
286 | "RESNAME"
287 | "RESULT"
288 | "RESULTS"
289 | "SOURCE"
290 | "SRCNAME"
291 | "TBLNAME"
292 )
293}
294
295#[derive(Debug, Clone, PartialEq, Eq)]
297pub struct SrcBlockHead<'a> {
298 pub lang: &'a str,
300 pub switches: &'a str,
302 pub headers: &'a str,
304}
305
306pub fn parse_src_begin(line: &str) -> Option<SrcBlockHead<'_>> {
308 let rest = strip_hash_plus(line.trim_start())?;
309 let after = strip_keyword_prefix(rest, "BEGIN_SRC")?;
310 let after = after.trim_start();
311 if after.is_empty() {
312 return None;
313 }
314 let (lang, rest) = first_word(after).unwrap_or((after, ""));
315 let rest = rest.trim_start();
316 let (switches, headers) = match rest.find(':') {
317 Some(i) => (rest[..i].trim(), rest[i..].trim()),
318 None => (rest.trim(), ""),
319 };
320 Some(SrcBlockHead {
321 lang,
322 switches,
323 headers,
324 })
325}
326
327pub fn parse_header_args(s: &str) -> Vec<(String, String)> {
329 let mut out = Vec::new();
330 let mut rest = s.trim();
331 while let Some(idx) = rest.find(':') {
332 rest = rest[idx + 1..].trim_start();
333 if rest.is_empty() {
334 break;
335 }
336 let (key, after) = match rest.find(char::is_whitespace) {
337 Some(i) => (&rest[..i], rest[i..].trim_start()),
338 None => (rest, ""),
339 };
340 if key.is_empty() {
341 break;
342 }
343 let (value, next) = next_header_value(after);
344 out.push((key.to_string(), value.to_string()));
345 rest = next;
346 }
347 out
348}
349
350fn next_header_value(s: &str) -> (&str, &str) {
351 if s.is_empty() || s.starts_with(':') {
352 return ("", s);
353 }
354 let bytes = s.as_bytes();
355 let mut i = 0;
356 while i < bytes.len() {
357 if bytes[i] == b':' && (i == 0 || bytes[i - 1].is_ascii_whitespace()) {
358 break;
359 }
360 i += 1;
361 }
362 while i > 0 && !s.is_char_boundary(i) {
364 i -= 1;
365 }
366 (s[..i].trim(), s[i..].trim_start())
367}
368
369pub fn noweb_refs(body: &str) -> Vec<&str> {
371 let mut refs = Vec::new();
372 let mut rest = body;
373 while let Some(start) = rest.find("<<") {
374 let after = &rest[start + 2..];
375 let Some(end) = after.find(">>") else {
376 break;
377 };
378 let inner = after[..end].trim();
379 if !inner.is_empty() && !inner.contains('\n') && !refs.contains(&inner) {
380 refs.push(inner);
381 }
382 rest = &after[end + 2..];
383 }
384 refs
385}
386
387pub fn inline_src_spans(text: &str) -> Vec<(&str, &str, &str)> {
389 let mut out = Vec::new();
390 let mut rest = text;
391 while let Some(idx) = rest.find("src_") {
392 let after = &rest[idx + 4..];
393 let lang_len = after
394 .find(|c: char| c.is_whitespace() || c == '[' || c == '{')
395 .unwrap_or(after.len());
396 if lang_len == 0 {
397 rest = &after[1.min(after.len())..];
398 continue;
399 }
400 let lang = &after[..lang_len];
401 let mut tail = &after[lang_len..];
402 let mut headers = "";
403 if let Some(inner) = tail.strip_prefix('[') {
404 let Some(end) = inner.find(']') else {
405 rest = tail;
406 continue;
407 };
408 headers = &inner[..end];
409 tail = &inner[end + 1..];
410 }
411 let Some(inner) = tail.strip_prefix('{') else {
412 rest = tail;
413 continue;
414 };
415 let Some(end) = inner.find('}') else {
416 rest = tail;
417 continue;
418 };
419 out.push((lang, headers, &inner[..end]));
420 rest = &inner[end + 1..];
421 }
422 out
423}
424
425pub fn inline_call_names(text: &str) -> Vec<&str> {
427 let mut out = Vec::new();
428 let mut rest = text;
429 while let Some(idx) = rest.find("call_") {
430 let after = &rest[idx + 5..];
431 let name_len = after
432 .find(|c: char| c.is_whitespace() || c == '[' || c == '(')
433 .unwrap_or(after.len());
434 if name_len == 0 {
435 rest = &after[1.min(after.len())..];
436 continue;
437 }
438 let name = &after[..name_len];
439 let tail = &after[name_len..];
440 if tail.starts_with('(') || tail.starts_with('[') {
441 out.push(name);
442 }
443 rest = tail;
444 }
445 out
446}
447
448fn strip_hash_plus(trimmed: &str) -> Option<&str> {
449 trimmed.strip_prefix("#+")
450}
451
452fn strip_keyword_prefix<'a>(s: &'a str, keyword: &str) -> Option<&'a str> {
453 if s.len() >= keyword.len()
454 && s.is_char_boundary(keyword.len())
455 && s[..keyword.len()].eq_ignore_ascii_case(keyword)
456 {
457 Some(&s[keyword.len()..])
458 } else {
459 None
460 }
461}
462
463fn headline_stars(line: &str) -> usize {
464 line.len() - line.trim_start_matches('*').len()
465}
466
467fn is_org_table_line(trimmed: &str) -> bool {
468 trimmed.starts_with('|')
469}
470
471fn is_fixed_width_line(line: &str) -> bool {
472 let trimmed = line.trim_start();
473 matches!(
474 trimmed.as_bytes(),
475 [b':'] | [b':', b' ', ..] | [b':', b'\t', ..]
476 ) && !opens_a_drawer(trimmed)
477 && !trimmed.eq_ignore_ascii_case(":END:")
478}
479
480fn is_org_list_line(line: &str) -> bool {
481 if is_headline(line) {
482 return false;
483 }
484 let trimmed = line.trim_start();
485 if trimmed.starts_with("- ") || trimmed.starts_with("+ ") {
486 return true;
487 }
488 let Some((token, rest)) = first_word(trimmed) else {
489 return false;
490 };
491 let rest = rest.trim_start();
492 if rest.is_empty() && !token.ends_with('.') && !token.ends_with(')') {
493 return false;
494 }
495 let bare = token.trim_end_matches(['.', ')']);
496 if bare.is_empty() || bare == token {
497 return false;
498 }
499 bare.chars().all(|c| c.is_ascii_digit())
500 || (bare.len() == 1 && bare.chars().all(|c| c.is_ascii_alphabetic()))
501}
502
503fn is_list_continuation(line: &str) -> bool {
504 !is_headline(line)
505 && (line.starts_with(' ') || line.starts_with('\t'))
506 && !line.trim().is_empty()
507}
508
509fn is_block_begin(trimmed: &str) -> bool {
510 let Some(rest) = trimmed.strip_prefix("#+") else {
511 return false;
512 };
513 starts_ignore_ascii(rest, "BEGIN_") || starts_ignore_ascii(rest, "BEGIN:")
514}
515
516fn is_block_end(trimmed: &str) -> bool {
517 let Some(rest) = trimmed.strip_prefix("#+") else {
518 return false;
519 };
520 starts_ignore_ascii(rest, "END_") || starts_ignore_ascii(rest, "END:")
521}
522
523fn starts_ignore_ascii(s: &str, prefix: &str) -> bool {
524 s.len() >= prefix.len()
525 && s.is_char_boundary(prefix.len())
526 && s[..prefix.len()].eq_ignore_ascii_case(prefix)
527}
528
529pub fn todo_keywords_from_preamble(preamble: &str) -> Vec<String> {
535 todo_keywords_from_lines(&preamble.lines().collect::<Vec<_>>())
536}
537
538pub fn todo_keywords_from_lines(lines: &[&str]) -> Vec<String> {
540 let mut keywords: Vec<String> = TODO_KEYWORDS.iter().map(|s| (*s).to_string()).collect();
541 for line in lines {
542 let trimmed = line.trim();
543 let Some(rest) = strip_file_keyword(trimmed, "TODO") else {
544 continue;
545 };
546 for token in rest.split_whitespace() {
547 if token == "|" {
548 continue;
549 }
550 let name = token.split('(').next().unwrap_or(token);
551 if name.is_empty() {
552 continue;
553 }
554 if !keywords.iter().any(|k| k == name) {
555 keywords.push(name.to_string());
556 }
557 }
558 }
559 keywords
560}
561
562pub fn filetags_from_preamble(preamble: &str) -> Vec<String> {
564 tag_settings_from_preamble(preamble).filetags
565}
566
567#[derive(Debug, Clone, PartialEq, Eq)]
569pub struct TagSpec {
570 pub name: String,
572 pub key: Option<char>,
574}
575
576#[derive(Debug, Clone, PartialEq, Eq)]
578pub struct TagSettings {
579 pub filetags: Vec<String>,
581 pub declared: Vec<TagSpec>,
583 pub exclusive: Vec<Vec<String>>,
585 pub hierarchies: Vec<(String, Vec<String>)>,
587 pub select_tags: Vec<String>,
589 pub exclude_tags: Vec<String>,
591}
592
593impl Default for TagSettings {
594 fn default() -> Self {
595 Self {
596 filetags: Vec::new(),
597 declared: Vec::new(),
598 exclusive: Vec::new(),
599 hierarchies: Vec::new(),
600 select_tags: vec!["export".into()],
601 exclude_tags: vec!["noexport".into()],
602 }
603 }
604}
605
606impl TagSettings {
607 pub fn all_tags(&self, own: &[String]) -> Vec<String> {
611 let mut tags = own.to_vec();
612 for tag in &self.filetags {
613 if !tags.iter().any(|seen| seen == tag) {
614 tags.push(tag.clone());
615 }
616 }
617 tags
618 }
619
620 pub fn matches_query(&self, own: &[String], needle: &str) -> bool {
623 let needle_l = needle.to_lowercase();
624 if needle_l.is_empty() {
625 return false;
626 }
627 let all = self.all_tags(own);
628 if all.iter().any(|tag| tag.to_lowercase().contains(&needle_l)) {
629 return true;
630 }
631 for (group, members) in &self.hierarchies {
632 if !group.to_lowercase().contains(&needle_l) {
633 continue;
634 }
635 if members
636 .iter()
637 .any(|m| all.iter().any(|tag| tag.eq_ignore_ascii_case(m)))
638 {
639 return true;
640 }
641 }
642 false
643 }
644
645 pub fn heading_exportable(&self, own: &[String]) -> bool {
652 !own.iter().any(|tag| {
653 self.exclude_tags
654 .iter()
655 .any(|ex| ex.eq_ignore_ascii_case(tag))
656 })
657 }
658}
659
660pub fn tag_settings_from_preamble(preamble: &str) -> TagSettings {
662 let mut settings = TagSettings::default();
663 let mut saw_select = false;
664 let mut saw_exclude = false;
665 for line in preamble.lines() {
666 let trimmed = line.trim();
667 if let Some(rest) = strip_file_keyword(trimmed, "FILETAGS") {
668 for tag in rest.trim().trim_matches(':').split(':') {
669 let tag = tag.trim();
670 if !tag.is_empty()
671 && tag.chars().all(is_org_tag_char)
672 && !settings.filetags.iter().any(|t| t == tag)
673 {
674 settings.filetags.push(tag.to_string());
675 }
676 }
677 continue;
678 }
679 if let Some(rest) = strip_file_keyword(trimmed, "TAGS") {
680 apply_tags_line(&mut settings, rest);
681 continue;
682 }
683 if let Some(rest) = strip_file_keyword(trimmed, "SELECT_TAGS") {
684 settings.select_tags = split_keyword_tags(rest);
685 saw_select = true;
686 continue;
687 }
688 if let Some(rest) = strip_file_keyword(trimmed, "EXCLUDE_TAGS") {
689 settings.exclude_tags = split_keyword_tags(rest);
690 saw_exclude = true;
691 }
692 }
693 if !saw_select {
694 settings.select_tags = vec!["export".into()];
695 }
696 if !saw_exclude {
697 settings.exclude_tags = vec!["noexport".into()];
698 }
699 settings
700}
701
702fn split_keyword_tags(rest: &str) -> Vec<String> {
703 let mut tags = Vec::new();
704 for tag in rest
705 .split(|c: char| c.is_whitespace() || c == ':' || c == ',')
706 .map(str::trim)
707 .filter(|t| !t.is_empty())
708 {
709 if tag.chars().all(is_org_tag_char) && !tags.iter().any(|t| t == tag) {
710 tags.push(tag.to_string());
711 }
712 }
713 tags
714}
715
716fn apply_tags_line(settings: &mut TagSettings, rest: &str) {
717 let tokens = tokenize_tags_line(rest);
718 let mut i = 0;
719 while i < tokens.len() {
720 match tokens[i].as_str() {
721 "{" | "[" => {
722 let exclusive = tokens[i] == "{";
723 let closer = if exclusive { "}" } else { "]" };
724 i += 1;
725 let mut names: Vec<TagSpec> = Vec::new();
726 let mut hierarchy_at = None;
727 while i < tokens.len() && tokens[i] != closer {
728 if tokens[i] == ":" {
729 hierarchy_at = Some(names.len());
730 i += 1;
731 continue;
732 }
733 if let Some(spec) = parse_tag_token(&tokens[i]) {
734 names.push(spec);
735 }
736 i += 1;
737 }
738 if i < tokens.len() && tokens[i] == closer {
739 i += 1;
740 }
741 let names_only: Vec<String> = names.iter().map(|s| s.name.clone()).collect();
742 if let Some(split) = hierarchy_at {
743 if split >= 1 {
744 let group = names[0].name.clone();
745 let members: Vec<String> = names_only.into_iter().skip(split).collect();
746 settings.hierarchies.push((group, members));
747 }
748 } else if exclusive && names_only.len() >= 2 {
749 settings.exclusive.push(names_only);
750 }
751 for spec in names {
752 if !settings.declared.iter().any(|d| d.name == spec.name) {
753 settings.declared.push(spec);
754 }
755 }
756 }
757 "\\n" => i += 1,
758 other => {
759 if let Some(spec) = parse_tag_token(other)
760 && !settings.declared.iter().any(|d| d.name == spec.name)
761 {
762 settings.declared.push(spec);
763 }
764 i += 1;
765 }
766 }
767 }
768}
769
770fn tokenize_tags_line(rest: &str) -> Vec<String> {
771 let mut tokens = Vec::new();
772 let chars: Vec<char> = rest.chars().collect();
773 let mut i = 0;
774 while i < chars.len() {
775 let c = chars[i];
776 if c.is_whitespace() {
777 i += 1;
778 continue;
779 }
780 if matches!(c, '{' | '}' | '[' | ']' | ':') {
781 tokens.push(c.to_string());
782 i += 1;
783 continue;
784 }
785 if c == '\\' && chars.get(i + 1) == Some(&'n') {
786 tokens.push("\\n".into());
787 i += 2;
788 continue;
789 }
790 let start = i;
791 while i < chars.len()
792 && !chars[i].is_whitespace()
793 && !matches!(chars[i], '{' | '}' | '[' | ']' | ':')
794 {
795 i += 1;
796 }
797 tokens.push(chars[start..i].iter().collect());
798 }
799 tokens
800}
801
802fn parse_tag_token(token: &str) -> Option<TagSpec> {
803 let token = token.trim();
804 if token.is_empty() {
805 return None;
806 }
807 if let Some(name) = token.strip_suffix(')')
808 && let Some((name, key)) = name.rsplit_once('(')
809 {
810 let name = name.trim();
811 let key = key.trim();
812 if !name.is_empty() && name.chars().all(is_org_tag_char) && key.chars().count() == 1 {
813 return Some(TagSpec {
814 name: name.to_string(),
815 key: key.chars().next(),
816 });
817 }
818 }
819 if token.chars().all(is_org_tag_char) {
820 return Some(TagSpec {
821 name: token.to_string(),
822 key: None,
823 });
824 }
825 None
826}
827
828pub const HOUSE_TAGS_LINES: &[&str] = &[
830 "#+TAGS: { bug(b) feature(f) task(t) chore(c) plan(p) }",
831 "#+TAGS: docs(d) perf ignore ARCHIVE",
832];
833
834pub const HOUSE_PRIORITIES_LINE: &str = "#+PRIORITIES: A C C";
839
840pub const PROTOCOL_VERSION: u32 = 1;
848
849pub const PROTOCOL_KEYWORD: &str = "VISSUE";
851
852pub fn protocol_from_preamble(preamble: &str) -> Option<u32> {
854 for line in preamble.lines() {
855 if let Some(n) = protocol_from_keyword_line(line) {
856 return Some(n);
857 }
858 }
859 None
860}
861
862fn protocol_from_keyword_line(line: &str) -> Option<u32> {
863 let rest = strip_file_keyword(line.trim(), PROTOCOL_KEYWORD)?;
864 let mut parts = rest.split_whitespace();
865 let first = parts.next()?;
866 if first.eq_ignore_ascii_case("protocol") {
867 parts.next()?.parse().ok()
868 } else {
869 first
870 .strip_prefix("protocol=")
871 .unwrap_or(first)
872 .parse()
873 .ok()
874 }
875}
876
877fn protocol_stamp_line() -> String {
878 format!("#+{PROTOCOL_KEYWORD}: {PROTOCOL_VERSION}")
879}
880
881#[derive(Debug, Clone, Copy, PartialEq, Eq)]
887pub struct PrioritySpec {
888 pub highest: char,
890 pub lowest: char,
892 pub default: char,
894}
895
896impl Default for PrioritySpec {
897 fn default() -> Self {
898 Self {
899 highest: 'A',
900 lowest: 'C',
901 default: 'C',
902 }
903 }
904}
905
906impl PrioritySpec {
907 pub fn contains(self, cookie: char) -> bool {
909 let (lo, hi) = if self.highest <= self.lowest {
910 (self.highest, self.lowest)
911 } else {
912 (self.lowest, self.highest)
913 };
914 cookie >= lo && cookie <= hi
915 }
916}
917
918pub fn priorities_from_preamble(preamble: &str) -> PrioritySpec {
920 for line in preamble.lines() {
921 let Some(rest) = strip_file_keyword(line.trim(), "PRIORITIES") else {
922 continue;
923 };
924 let mut toks = rest.split_whitespace();
925 let (Some(h), Some(l), Some(d)) = (toks.next(), toks.next(), toks.next()) else {
926 continue;
927 };
928 let (Some(highest), Some(lowest), Some(default)) =
929 (h.chars().next(), l.chars().next(), d.chars().next())
930 else {
931 continue;
932 };
933 return PrioritySpec {
934 highest,
935 lowest,
936 default,
937 };
938 }
939 PrioritySpec::default()
940}
941
942pub fn is_gcal_event_id(id: &str) -> bool {
944 let id = id.trim();
945 let Some((left, right)) = id.split_once('/') else {
946 return false;
947 };
948 !left.is_empty()
949 && !right.is_empty()
950 && !left.contains(char::is_whitespace)
951 && !right.contains(char::is_whitespace)
952}
953
954pub fn org_property_is_set(
956 properties: &std::collections::BTreeMap<String, String>,
957 key: &str,
958) -> bool {
959 properties.get(key).is_some_and(|raw| {
960 let v = raw.trim();
961 !v.is_empty() && !v.eq_ignore_ascii_case("nil") && v != "0"
962 })
963}
964
965pub fn merge_setupfile_settings(preamble: &str, base_dir: Option<&std::path::Path>) -> String {
970 let mut seen = std::collections::HashSet::new();
971 let mut out = String::new();
972 collect_setupfile_settings(preamble, base_dir, &mut seen, 0, &mut out);
973 if !out.is_empty() && !out.ends_with('\n') {
974 out.push('\n');
975 }
976 out.push_str(preamble);
977 out
978}
979
980fn collect_setupfile_settings(
981 text: &str,
982 base_dir: Option<&std::path::Path>,
983 seen: &mut std::collections::HashSet<std::path::PathBuf>,
984 depth: u8,
985 out: &mut String,
986) {
987 if depth > 16 {
988 return;
989 }
990 for line in text.lines() {
991 let Some(rest) = strip_file_keyword(line.trim(), "SETUPFILE") else {
992 continue;
993 };
994 let spec = rest.trim().trim_matches('"').trim_matches('\'').trim();
995 if spec.is_empty()
996 || spec.contains("://")
997 || spec.starts_with("http:")
998 || spec.starts_with("https:")
999 {
1000 continue;
1001 }
1002 let path = match base_dir {
1003 Some(dir) => dir.join(spec),
1004 None => std::path::PathBuf::from(spec),
1005 };
1006 let key = path.canonicalize().unwrap_or(path.clone());
1007 if !seen.insert(key) {
1008 continue;
1009 }
1010 let Ok(body) = std::fs::read_to_string(&path) else {
1011 continue;
1012 };
1013 let keywords: String = body
1014 .lines()
1015 .filter(|l| l.trim_start().starts_with("#+"))
1016 .collect::<Vec<_>>()
1017 .join("\n");
1018 if !keywords.is_empty() {
1019 out.push_str(&keywords);
1020 out.push('\n');
1021 }
1022 collect_setupfile_settings(&keywords, path.parent(), seen, depth + 1, out);
1023 }
1024}
1025
1026pub fn preamble_has_keyword(preamble: &str, name: &str) -> bool {
1028 preamble
1029 .lines()
1030 .any(|line| strip_file_keyword(line.trim(), name).is_some())
1031}
1032
1033pub fn ensure_org_preamble(preamble: &str, project: &str) -> String {
1042 if preamble.trim().is_empty() {
1043 return preamble.to_string();
1044 }
1045 let mut lines: Vec<String> = preamble.lines().map(str::to_string).collect();
1046 let insert_at = lines
1047 .iter()
1048 .position(|line| strip_file_keyword(line.trim(), "TITLE").is_some())
1049 .map(|i| i + 1)
1050 .unwrap_or(0);
1051 let mut extra = Vec::new();
1052 if !preamble_has_keyword(preamble, "CATEGORY") {
1053 extra.push(format!("#+CATEGORY: {project}"));
1054 }
1055 if !preamble_has_keyword(preamble, "FILETAGS") {
1056 extra.push(format!("#+FILETAGS: :issues:{project}:noexport:"));
1057 }
1058 if !preamble_has_keyword(preamble, "TAGS") {
1059 extra.extend(HOUSE_TAGS_LINES.iter().map(|s| (*s).to_string()));
1060 }
1061 if !preamble_has_keyword(preamble, "EXCLUDE_TAGS") {
1062 extra.push("#+EXCLUDE_TAGS: noexport".to_string());
1063 }
1064 if !preamble_has_keyword(preamble, "SELECT_TAGS") {
1065 extra.push("#+SELECT_TAGS: export".to_string());
1066 }
1067 if !preamble_has_keyword(preamble, "PRIORITIES") {
1068 extra.push(HOUSE_PRIORITIES_LINE.to_string());
1069 }
1070 for (offset, line) in extra.into_iter().enumerate() {
1071 lines.insert(insert_at + offset, line);
1072 }
1073 ensure_filetags_has_noexport(&mut lines);
1074 ensure_protocol_stamp(&mut lines);
1075 let out = lines.join("\n");
1076 if out == preamble {
1077 preamble.to_string()
1078 } else {
1079 out
1080 }
1081}
1082
1083fn ensure_protocol_stamp(lines: &mut Vec<String>) {
1084 let stamp = protocol_stamp_line();
1085 for line in lines.iter_mut() {
1086 if strip_file_keyword(line.trim(), PROTOCOL_KEYWORD).is_none() {
1087 continue;
1088 }
1089 match protocol_from_keyword_line(line) {
1090 Some(n) if n >= PROTOCOL_VERSION => {}
1091 _ => *line = stamp,
1092 }
1093 return;
1094 }
1095 let insert_at = lines
1096 .iter()
1097 .position(|line| strip_file_keyword(line.trim(), "TITLE").is_some())
1098 .map(|i| i + 1)
1099 .unwrap_or(0);
1100 lines.insert(insert_at, stamp);
1101}
1102
1103fn ensure_filetags_has_noexport(lines: &mut [String]) {
1104 for line in lines.iter_mut() {
1105 let Some(rest) = strip_file_keyword(line.trim(), "FILETAGS") else {
1106 continue;
1107 };
1108 let tags: Vec<&str> = rest
1109 .trim()
1110 .trim_matches(':')
1111 .split(':')
1112 .map(str::trim)
1113 .filter(|t| !t.is_empty())
1114 .collect();
1115 if tags.iter().any(|t| t.eq_ignore_ascii_case("noexport")) {
1116 return;
1117 }
1118 let mut all = tags;
1119 all.push("noexport");
1120 *line = format!("#+FILETAGS: :{}:", all.join(":"));
1121 return;
1122 }
1123}
1124
1125pub fn settle_heading_classifiers(
1130 org_tags: &mut Vec<String>,
1131 properties: &mut std::collections::BTreeMap<String, String>,
1132) {
1133 fn push_tag(org_tags: &mut Vec<String>, tag: &str) {
1134 if !tag.is_empty()
1135 && tag.chars().all(is_org_tag_char)
1136 && !org_tags.iter().any(|seen| seen == tag)
1137 {
1138 org_tags.push(tag.to_string());
1139 }
1140 }
1141 for key in ["VISSUE_TYPE", "TYPE"] {
1142 if let Some(kind) = properties.get(key) {
1143 push_tag(org_tags, kind.trim());
1144 }
1145 }
1146 if let Some(raw) = properties.get(crate::model::TAGS_PROPERTY).cloned() {
1147 let mut kept = Vec::new();
1148 for tag in raw
1149 .split([',', ':'])
1150 .map(str::trim)
1151 .filter(|t| !t.is_empty())
1152 {
1153 if tag.chars().all(is_org_tag_char) {
1154 push_tag(org_tags, tag);
1155 } else {
1156 kept.push(tag.to_string());
1157 }
1158 }
1159 if kept.is_empty() {
1160 properties.remove(crate::model::TAGS_PROPERTY);
1161 } else {
1162 properties.insert(crate::model::TAGS_PROPERTY.to_string(), kept.join(","));
1163 }
1164 }
1165}
1166
1167pub const COMPUTED_SPECIALS: &[&str] = &[
1170 "ALLTAGS",
1171 "BLOCKED",
1172 "CLOCKSUM",
1173 "CLOCKSUM_T",
1174 "FILE",
1175 "ITEM",
1176 "PRIORITY",
1177 "TAGS",
1178 "TIMESTAMP",
1179 "TIMESTAMP_IA",
1180 "TODO",
1181];
1182
1183pub const SETTABLE_SPECIALS: &[&str] = &[
1186 "ARCHIVE",
1187 "CATEGORY",
1188 "COLUMNS",
1189 "COOKIE_DATA",
1190 "LOGGING",
1191 "ORDERED",
1192 "STYLE",
1193];
1194
1195const EDNA_ATOMS: &[&str] = &[
1197 "ancestors",
1198 "chain-siblings",
1199 "children",
1200 "descendants",
1201 "file-progress",
1202 "first-child",
1203 "has-property",
1204 "heading",
1205 "headings",
1206 "id",
1207 "ids",
1208 "last-child",
1209 "match",
1210 "next-sibling",
1211 "olp",
1212 "parent",
1213 "prev-sibling",
1214 "previous-sibling",
1215 "relatives",
1216 "rest-of-siblings",
1217 "siblings",
1218 "todo-state",
1219 "todo-state!",
1220];
1221
1222pub fn split_id_list(raw: &str) -> Vec<String> {
1224 raw.split(|c: char| c == ',' || c.is_whitespace())
1225 .map(str::trim)
1226 .filter(|x| !x.is_empty())
1227 .map(str::to_string)
1228 .collect()
1229}
1230
1231pub fn is_edna_blocker(raw: &str) -> bool {
1235 let trimmed = raw.trim();
1236 if trimmed.contains('(') {
1237 return true;
1238 }
1239 trimmed.split_whitespace().any(|tok| {
1240 let atom = tok.trim_end_matches('!');
1241 EDNA_ATOMS
1242 .iter()
1243 .any(|known| atom.eq_ignore_ascii_case(known))
1244 })
1245}
1246
1247pub fn edna_blocker_id_refs(raw: &str) -> Vec<&str> {
1249 let mut ids = Vec::new();
1250 let mut rest = raw;
1251 while let Some(start) = rest.find('(') {
1252 let Some(end) = rest[start + 1..].find(')') else {
1253 break;
1254 };
1255 let inner = &rest[start + 1..start + 1 + end];
1256 for id in inner.split(|c: char| c == ',' || c.is_whitespace()) {
1257 let id = id.trim();
1258 if !id.is_empty() && !id.contains('"') && !ids.contains(&id) {
1259 ids.push(id);
1260 }
1261 }
1262 rest = &rest[start + 1 + end + 1..];
1263 }
1264 ids
1265}
1266
1267pub fn edna_blocker_ids(raw: &str) -> Vec<String> {
1269 edna_blocker_id_refs(raw)
1270 .into_iter()
1271 .map(str::to_string)
1272 .collect()
1273}
1274
1275pub fn blocker_ids_from_properties(
1279 properties: &std::collections::BTreeMap<String, String>,
1280) -> Vec<String> {
1281 let mut ids = Vec::new();
1282 for key in ["VISSUE_BLOCKED_BY", "BLOCKED_BY", "BLOCKEDBY"] {
1283 if let Some(raw) = properties.get(key) {
1284 for id in split_id_list(raw) {
1285 if !ids.iter().any(|seen| seen == &id) {
1286 ids.push(id);
1287 }
1288 }
1289 }
1290 }
1291 if let Some(raw) = properties.get("BLOCKER") {
1292 let extra = if is_edna_blocker(raw) {
1293 edna_blocker_ids(raw)
1294 } else {
1295 split_id_list(raw)
1296 };
1297 for id in extra {
1298 if !ids.iter().any(|seen| seen == &id) {
1299 ids.push(id);
1300 }
1301 }
1302 }
1303 ids
1304}
1305
1306pub fn effort_from_properties(
1309 properties: &std::collections::BTreeMap<String, String>,
1310) -> Option<&str> {
1311 properties
1312 .get("Effort")
1313 .or_else(|| properties.get("EFFORT"))
1314 .map(|s| s.trim())
1315 .filter(|s| !s.is_empty())
1316}
1317
1318pub fn is_org_effort(raw: &str) -> bool {
1320 let s = raw.trim();
1321 if s.is_empty() {
1322 return false;
1323 }
1324 if let Some((h, m)) = s.split_once(':') {
1325 return !h.is_empty()
1326 && h.chars().all(|c| c.is_ascii_digit())
1327 && !m.is_empty()
1328 && m.chars().all(|c| c.is_ascii_digit());
1329 }
1330 let (num, unit) = s.split_at(
1331 s.find(|c: char| !c.is_ascii_digit() && c != '.')
1332 .unwrap_or(s.len()),
1333 );
1334 !num.is_empty() && matches!(unit, "h" | "d" | "m" | "w" | "min" | "")
1335}
1336
1337fn strip_file_keyword<'a>(trimmed: &'a str, name: &str) -> Option<&'a str> {
1338 let rest = trimmed.strip_prefix("#+")?;
1339 let (key, value) = rest.split_once(':')?;
1340 if key.eq_ignore_ascii_case(name) {
1341 Some(value)
1342 } else {
1343 None
1344 }
1345}
1346
1347#[derive(Debug, Clone, PartialEq, Eq)]
1349pub struct HeadlineBits<'a> {
1350 pub keyword: Option<&'a str>,
1352 pub priority: Option<char>,
1354 pub commented: bool,
1356 pub rest: &'a str,
1358}
1359
1360pub fn parse_headline_bits<'a>(after_stars: &'a str, keywords: &[String]) -> HeadlineBits<'a> {
1362 let trimmed = after_stars.trim();
1363 let mut rest = trimmed;
1364 let mut keyword = None;
1365 if let Some((word, after)) = first_word(rest)
1366 && is_listed_keyword(word, keywords)
1367 {
1368 keyword = Some(word);
1369 rest = after.trim_start();
1370 }
1371 let mut priority = None;
1372 if let Some((p, after)) = parse_priority_cookie(rest) {
1373 priority = Some(p);
1374 rest = after.trim_start();
1375 }
1376 let mut commented = false;
1377 if let Some((word, after)) = first_word(rest)
1378 && word.eq_ignore_ascii_case("COMMENT")
1379 {
1380 commented = true;
1381 rest = after.trim_start();
1382 }
1383 HeadlineBits {
1384 keyword,
1385 priority,
1386 commented,
1387 rest,
1388 }
1389}
1390
1391fn first_word(s: &str) -> Option<(&str, &str)> {
1392 let s = s.trim_start();
1393 if s.is_empty() {
1394 return None;
1395 }
1396 match s.find(char::is_whitespace) {
1397 Some(i) => Some((&s[..i], &s[i..])),
1398 None => Some((s, "")),
1399 }
1400}
1401
1402fn is_listed_keyword(word: &str, keywords: &[String]) -> bool {
1403 keywords.iter().any(|k| k == word)
1404}
1405
1406pub fn is_issue_headline(line: &str, keywords: &[String]) -> bool {
1408 let Some(after) = line.strip_prefix("* ") else {
1409 return false;
1410 };
1411 let bits = parse_headline_bits(after, keywords);
1412 bits.keyword.is_some() && !bits.commented
1413}
1414
1415pub fn parse_priority_cookie(after: &str) -> Option<(char, &str)> {
1417 let rest = after.strip_prefix("[#")?;
1418 let mut chars = rest.char_indices();
1419 let (_, priority) = chars.next()?;
1420 let (close, bracket) = chars.next()?;
1421 if bracket != ']' {
1422 return None;
1423 }
1424 Some((priority, &rest[close + 1..]))
1425}
1426
1427pub fn split_statistics_cookies(text: &str) -> (String, Option<String>) {
1431 let mut trimmed = text.trim_end().to_string();
1432 let mut cookies = Vec::new();
1433 while let Some(open) = trimmed.rfind('[') {
1434 if !trimmed.ends_with(']') {
1435 break;
1436 }
1437 let cookie = &trimmed[open..];
1438 if !is_statistics_cookie(cookie) {
1439 break;
1440 }
1441 let prefix = trimmed[..open].trim_end();
1442 if open > 0 && !trimmed[..open].ends_with(char::is_whitespace) {
1443 break;
1444 }
1445 cookies.push(cookie.to_string());
1446 trimmed = prefix.to_string();
1447 }
1448 cookies.reverse();
1449 if cookies.is_empty() {
1450 (text.trim_end().to_string(), None)
1451 } else {
1452 (trimmed, Some(cookies.join(" ")))
1453 }
1454}
1455
1456fn is_statistics_cookie(cookie: &str) -> bool {
1457 let Some(inner) = cookie.strip_prefix('[').and_then(|s| s.strip_suffix(']')) else {
1458 return false;
1459 };
1460 if let Some((a, b)) = inner.split_once('/') {
1461 return !a.is_empty()
1462 && !b.is_empty()
1463 && a.chars().all(|c| c.is_ascii_digit())
1464 && b.chars().all(|c| c.is_ascii_digit());
1465 }
1466 inner
1467 .strip_suffix('%')
1468 .is_some_and(|n| !n.is_empty() && n.chars().all(|c| c.is_ascii_digit()))
1469}
1470
1471pub fn take_timestamp(s: &str) -> Option<(&str, &str)> {
1478 let start = s.trim_start();
1479 let leading = s.len() - start.len();
1480 if start.starts_with("<%%") {
1481 let end = start.find('>')?;
1482 let consumed = leading + end + 1;
1483 return Some((s[leading..consumed].trim_end(), s[consumed..].trim_start()));
1484 }
1485 let close = match start.chars().next()? {
1486 '<' => '>',
1487 '[' => ']',
1488 _ => return None,
1489 };
1490 let end = start.find(close)?;
1491 let mut consumed = leading + end + 1;
1492 let rest = &s[consumed..];
1493 if let Some(after) = rest.strip_prefix("--") {
1494 let after = after.trim_start();
1495 if after.starts_with('<') || after.starts_with('[') || after.starts_with("<%%") {
1496 let close2 = if after.starts_with('[') { ']' } else { '>' };
1497 let end2 = after.find(close2)?;
1498 consumed = s.len() - after.len() + end2 + 1;
1499 }
1500 }
1501 Some((s[leading..consumed].trim_end(), s[consumed..].trim_start()))
1502}
1503
1504pub fn parse_planning_line(line: &str) -> Vec<(String, String)> {
1510 let mut rest = line.trim();
1511 let mut found = Vec::new();
1512 while !rest.is_empty() {
1513 let Some(key) = PLANNING_KEYS
1514 .iter()
1515 .find(|key| rest.starts_with(&format!("{key}:")))
1516 else {
1517 return Vec::new();
1518 };
1519 let after = rest[key.len() + 1..].trim_start();
1520 let Some((ts, next)) = take_timestamp(after) else {
1521 return Vec::new();
1522 };
1523 found.push(((*key).to_string(), ts.to_string()));
1524 rest = next;
1525 }
1526 found
1527}
1528
1529pub fn is_planning_line(trimmed: &str) -> bool {
1531 PLANNING_KEYS
1532 .iter()
1533 .any(|key| trimmed.starts_with(key) && trimmed[key.len()..].starts_with(':'))
1534}
1535
1536pub fn org_link_targets(body: &str, known_ids: &std::collections::HashSet<&str>) -> Vec<String> {
1542 let mut targets = Vec::new();
1543 let mut rest = body;
1544 while let Some(start) = rest.find("[[") {
1545 let after_start = &rest[start + 2..];
1546 let Some(end) = after_start.find("]]") else {
1547 break;
1548 };
1549 let raw = &after_start[..end];
1550 let target = raw.split_once("][").map_or(raw, |(target, _)| target);
1551 push_link_target(&mut targets, target, known_ids);
1552 rest = &after_start[end + 2..];
1553 }
1554 rest = body;
1555 while let Some(start) = rest.find('<') {
1556 let after = &rest[start + 1..];
1557 let Some(end) = after.find('>') else {
1558 break;
1559 };
1560 push_link_target(&mut targets, &after[..end], known_ids);
1561 rest = &after[end + 1..];
1562 }
1563 rest = body;
1564 while let Some(start) = rest.find("id:") {
1565 let after = &rest[start + 3..];
1566 let len = after
1567 .find(|c: char| !c.is_ascii_alphanumeric() && c != '_' && c != '-')
1568 .unwrap_or(after.len());
1569 let id = &after[..len];
1570 if !id.is_empty() && known_ids.contains(id) && !targets.iter().any(|t| t == id) {
1571 targets.push(id.to_string());
1572 }
1573 rest = &after[len.max(1)..];
1574 }
1575 targets
1576}
1577
1578fn push_link_target(
1579 targets: &mut Vec<String>,
1580 raw: &str,
1581 known_ids: &std::collections::HashSet<&str>,
1582) {
1583 let target = raw.trim();
1584 let target = target.strip_prefix("id:").unwrap_or(target);
1585 let target = target.rsplit_once("::").map_or(target, |(_, fragment)| {
1586 fragment.strip_prefix('#').unwrap_or(fragment)
1587 });
1588 let target = target.strip_prefix('#').unwrap_or(target);
1589 if known_ids.contains(target) && !targets.iter().any(|t| t == target) {
1590 targets.push(target.to_string());
1591 }
1592}
1593
1594pub fn property_key_and_append(key: &str) -> (&str, bool) {
1598 match key.strip_suffix('+') {
1599 Some(bare) if !bare.is_empty() => (bare, true),
1600 _ => (key, false),
1601 }
1602}
1603
1604#[cfg(test)]
1605mod tests {
1606 use super::*;
1607 use std::collections::HashSet;
1608
1609 fn house() -> Vec<String> {
1610 TODO_KEYWORDS.iter().map(|s| (*s).to_string()).collect()
1611 }
1612
1613 #[test]
1614 fn a_headline_needs_stars_then_a_space_at_column_zero() {
1615 assert!(is_headline("* TODO a title"));
1616 assert!(is_headline("*** deeper"));
1617 assert!(is_top_level_headline("* TODO a title"));
1618 assert!(!is_top_level_headline("** child"));
1619 assert!(!is_headline("**bold** at the start of a line"));
1620 assert!(!is_headline(" * indented is not a headline"));
1621 assert!(!is_headline("not a headline"));
1622 }
1623
1624 #[test]
1625 fn greater_and_dynamic_blocks_hide_their_contents() {
1626 let mut nest = BlockNest::new();
1627 assert!(!nest.inside());
1628 assert!(nest.observe("#+BEGIN_SRC org"));
1629 assert!(nest.inside());
1630 assert!(nest.observe("* TODO quoted"));
1631 assert!(nest.observe("#+begin_example"));
1632 assert!(nest.observe("* still quoted"));
1633 assert!(nest.observe("#+end_example"));
1634 assert!(nest.inside());
1635 assert!(nest.observe("#+END_SRC"));
1636 assert!(!nest.inside());
1637 assert!(nest.observe(" #+BEGIN: clocktable :scope file"));
1638 assert!(nest.observe("* TODO inside clocktable"));
1639 assert!(nest.observe(" #+END:"));
1640 assert!(!nest.inside());
1641 }
1642
1643 #[test]
1644 fn file_local_todo_keywords_accumulate_and_keep_the_house_set() {
1645 let keys = todo_keywords_from_preamble(
1646 "#+TITLE: x\n#+TODO: TODO(t) WAIT(w@) | DONE(d!)\n#+TODO: HOLD | CANCELLED\n",
1647 );
1648 for expected in [
1649 "TODO",
1650 "STARTED",
1651 "BLOCKED",
1652 "DONE",
1653 "CANCELLED",
1654 "WAIT",
1655 "HOLD",
1656 ] {
1657 assert!(
1658 keys.iter().any(|k| k == expected),
1659 "{keys:?} missing {expected}"
1660 );
1661 }
1662 }
1663
1664 #[test]
1665 fn comment_and_section_headlines_are_not_issues() {
1666 let keys = house();
1667 assert!(is_issue_headline("* TODO Ship it", &keys));
1668 assert!(is_issue_headline("* DONE [#A] Ship it", &keys));
1669 assert!(!is_issue_headline("* COMMENT Archive", &keys));
1670 assert!(!is_issue_headline("* TODO COMMENT hidden", &keys));
1671 assert!(!is_issue_headline("* Notes", &keys));
1672 assert!(!is_issue_headline("** TODO child", &keys));
1673 }
1674
1675 #[test]
1676 fn a_file_local_keyword_is_an_issue() {
1677 let keys = todo_keywords_from_preamble("#+TODO: TODO WAIT | DONE\n");
1678 assert!(is_issue_headline("* WAIT Parked", &keys));
1679 assert!(!is_issue_headline("* HOLD Parked", &keys));
1680 }
1681
1682 #[test]
1683 fn statistics_cookies_split_off_the_title() {
1684 assert_eq!(
1685 split_statistics_cookies("Break it down [2/5]"),
1686 ("Break it down".into(), Some("[2/5]".into()))
1687 );
1688 assert_eq!(
1689 split_statistics_cookies("Break it down [2/5] [40%]"),
1690 ("Break it down".into(), Some("[2/5] [40%]".into()))
1691 );
1692 assert_eq!(
1693 split_statistics_cookies("Array [2/3] leftover"),
1694 ("Array [2/3] leftover".into(), None)
1695 );
1696 assert_eq!(
1697 split_statistics_cookies("Not a cookie [n/a]"),
1698 ("Not a cookie [n/a]".into(), None)
1699 );
1700 }
1701
1702 #[test]
1703 fn timestamps_include_ranges_repeaters_and_diary_sexps() {
1704 let (ts, rest) = take_timestamp("<2026-09-01 Tue +1w -2d> leftover").unwrap();
1705 assert_eq!(ts, "<2026-09-01 Tue +1w -2d>");
1706 assert_eq!(rest, "leftover");
1707 let (ts, rest) = take_timestamp("<2026-09-01 Tue>--<2026-09-08 Tue>").unwrap();
1708 assert_eq!(ts, "<2026-09-01 Tue>--<2026-09-08 Tue>");
1709 assert!(rest.is_empty());
1710 let (ts, _) = take_timestamp("[2026-09-01 Tue 09:00-17:00]").unwrap();
1711 assert_eq!(ts, "[2026-09-01 Tue 09:00-17:00]");
1712 let (ts, rest) = take_timestamp("<%%(diary-float t 4 2)> next").unwrap();
1713 assert_eq!(ts, "<%%(diary-float t 4 2)>");
1714 assert_eq!(rest, "next");
1715 }
1716
1717 #[test]
1718 fn a_planning_line_keeps_a_range_and_rejects_prose() {
1719 let found = parse_planning_line(
1720 "CLOSED: [2026-08-14 Fri 03:33] SCHEDULED: <2026-09-01 Tue>--<2026-09-08 Tue> DEADLINE: <2026-09-15 Mon +1w>",
1721 );
1722 assert_eq!(found.len(), 3, "{found:?}");
1723 assert_eq!(found[1].1, "<2026-09-01 Tue>--<2026-09-08 Tue>");
1724 assert_eq!(found[2].1, "<2026-09-15 Mon +1w>");
1725 assert!(parse_planning_line("DEADLINE: is discussed in the design note.").is_empty());
1726 }
1727
1728 #[test]
1729 fn org_links_include_brackets_angles_and_bare_ids() {
1730 let known: HashSet<&str> = ["atlas-1a2b", "beacon-5j6k"].into_iter().collect();
1731 let body =
1732 "See [[id:atlas-1a2b][the parser]] and <id:beacon-5j6k> plus id:atlas-1a2b again.";
1733 let found = org_link_targets(body, &known);
1734 assert_eq!(found, vec!["atlas-1a2b", "beacon-5j6k"]);
1735 }
1736
1737 #[test]
1738 fn filetags_parse_the_in_buffer_keyword() {
1739 assert_eq!(
1740 filetags_from_preamble("#+FILETAGS: :issues:parser:\n"),
1741 vec!["issues", "parser"]
1742 );
1743 }
1744
1745 #[test]
1746 fn tag_settings_parse_groups_and_keys() {
1747 let settings = tag_settings_from_preamble(
1748 "#+FILETAGS: :issues:demo:noexport:\n\
1749 #+TAGS: { bug(b) feature(f) task(t) }\n\
1750 #+TAGS: [ area : core cli ]\n\
1751 #+TAGS: docs(d) perf\n\
1752 #+EXCLUDE_TAGS: noexport\n\
1753 #+SELECT_TAGS: export\n",
1754 );
1755 assert_eq!(settings.filetags, vec!["issues", "demo", "noexport"]);
1756 assert_eq!(
1757 settings
1758 .declared
1759 .iter()
1760 .map(|s| (s.name.as_str(), s.key))
1761 .collect::<Vec<_>>(),
1762 vec![
1763 ("bug", Some('b')),
1764 ("feature", Some('f')),
1765 ("task", Some('t')),
1766 ("area", None),
1767 ("core", None),
1768 ("cli", None),
1769 ("docs", Some('d')),
1770 ("perf", None),
1771 ]
1772 );
1773 assert_eq!(
1774 settings.exclusive,
1775 vec![vec![
1776 "bug".to_string(),
1777 "feature".to_string(),
1778 "task".to_string()
1779 ]]
1780 );
1781 assert_eq!(
1782 settings.hierarchies,
1783 vec![("area".to_string(), vec!["core".into(), "cli".into()])]
1784 );
1785 let own = vec!["core".to_string(), "bug".to_string()];
1786 assert!(settings.matches_query(&own, "area"));
1787 assert!(settings.matches_query(&own, "issues"));
1788 assert!(settings.heading_exportable(&own));
1789 assert!(!settings.heading_exportable(&["noexport".into()]));
1790 assert_eq!(
1791 settings.all_tags(&own),
1792 vec!["core", "bug", "issues", "demo", "noexport"]
1793 );
1794 }
1795
1796 #[test]
1797 fn ensure_org_preamble_inserts_category_and_filetags() {
1798 let raw = "#+TITLE: demo issues\n#+TODO: TODO | DONE";
1799 let out = ensure_org_preamble(raw, "demo");
1800 assert!(out.contains("#+VISSUE: 1"), "{out}");
1801 assert!(out.contains("#+CATEGORY: demo"), "{out}");
1802 assert!(out.contains("#+FILETAGS: :issues:demo:noexport:"), "{out}");
1803 assert!(
1804 out.contains("#+TAGS: { bug(b) feature(f) task(t) chore(c) plan(p) }"),
1805 "{out}"
1806 );
1807 assert!(out.contains("#+EXCLUDE_TAGS: noexport"), "{out}");
1808 assert!(out.contains("#+SELECT_TAGS: export"), "{out}");
1809 assert!(out.contains("#+PRIORITIES: A C C"), "{out}");
1810 assert!(out.find("#+TITLE:").unwrap() < out.find("#+CATEGORY:").unwrap());
1811 assert_eq!(ensure_org_preamble(&out, "demo"), out);
1812 let kept = "#+TITLE: demo issues\n#+FILETAGS: :issues:demo:\n#+TODO: TODO | DONE";
1813 let healed = ensure_org_preamble(kept, "demo");
1814 assert!(
1815 healed.contains("#+FILETAGS: :issues:demo:noexport:"),
1816 "existing FILETAGS gain noexport: {healed}"
1817 );
1818 let old = "#+TITLE: demo issues\n#+VISSUE: 0\n#+CATEGORY: demo\n";
1819 let bumped = ensure_org_preamble(old, "demo");
1820 assert!(bumped.contains("#+VISSUE: 1"), "{bumped}");
1821 assert!(!bumped.contains("#+VISSUE: 0"), "{bumped}");
1822 let future = "#+TITLE: demo issues\n#+VISSUE: 99\n#+CATEGORY: demo\n";
1823 let left = ensure_org_preamble(future, "demo");
1824 assert!(left.contains("#+VISSUE: 99"), "{left}");
1825 }
1826
1827 #[test]
1828 fn protocol_from_preamble_reads_the_vissue_keyword() {
1829 assert_eq!(protocol_from_preamble("#+VISSUE: 1\n"), Some(1));
1830 assert_eq!(protocol_from_preamble("#+VISSUE: protocol 2\n"), Some(2));
1831 assert_eq!(protocol_from_preamble("#+VISSUE: protocol=3\n"), Some(3));
1832 assert_eq!(protocol_from_preamble("#+TITLE: x\n"), None);
1833 }
1834
1835 #[test]
1836 fn priorities_from_preamble_reads_highest_lowest_default() {
1837 let spec = priorities_from_preamble("#+PRIORITIES: A D B\n");
1838 assert_eq!(spec.highest, 'A');
1839 assert_eq!(spec.lowest, 'D');
1840 assert_eq!(spec.default, 'B');
1841 assert!(spec.contains('C'));
1842 assert!(!spec.contains('E'));
1843 assert_eq!(priorities_from_preamble("").default, 'C');
1844 }
1845
1846 #[test]
1847 fn gcal_event_ids_are_not_org_ids() {
1848 assert!(is_gcal_event_id("abc123/primary@group.calendar.google.com"));
1849 assert!(is_gcal_event_id("evt/cal"));
1850 assert!(is_gcal_event_id("abc/def/ghi"));
1851 assert!(!is_gcal_event_id("atlas-1a2b"));
1852 assert!(!is_gcal_event_id("no-slash"));
1853 }
1854
1855 #[test]
1856 fn setupfile_merges_local_inbuffer_settings() {
1857 let dir = tempfile::tempdir().unwrap();
1858 let setup = dir.path().join("house.org");
1859 std::fs::write(
1860 &setup,
1861 "#+TODO: TODO HOLD | DONE\n#+PRIORITIES: A D C\n* not a keyword\n",
1862 )
1863 .unwrap();
1864 let preamble = format!(
1865 "#+TITLE: x\n#+SETUPFILE: {}\n#+CATEGORY: x\n",
1866 setup.display()
1867 );
1868 let merged = merge_setupfile_settings(&preamble, Some(dir.path()));
1869 assert!(merged.contains("#+TODO: TODO HOLD | DONE"), "{merged}");
1870 assert!(merged.contains("#+PRIORITIES: A D C"), "{merged}");
1871 assert!(merged.contains("#+CATEGORY: x"), "{merged}");
1872 assert!(!merged.contains("* not a keyword"), "{merged}");
1873 assert_eq!(priorities_from_preamble(&merged).lowest, 'D');
1874 }
1875
1876 #[test]
1877 fn edna_blocker_is_not_an_id_list() {
1878 assert!(is_edna_blocker("prev-sibling"));
1879 assert!(is_edna_blocker("ids(atlas-1a2b atlas-3e4f)"));
1880 assert!(is_edna_blocker("headings(\"Ship it\")"));
1881 assert!(!is_edna_blocker("atlas-1a2b"));
1882 assert!(!is_edna_blocker("atlas-1a2b beacon-5j6k"));
1883 assert_eq!(
1884 edna_blocker_ids("ids(atlas-1a2b atlas-3e4f) next-sibling"),
1885 vec!["atlas-1a2b", "atlas-3e4f"]
1886 );
1887 let mut props = std::collections::BTreeMap::new();
1888 props.insert("BLOCKER".into(), "atlas-1a2b atlas-3e4f".into());
1889 assert_eq!(
1890 blocker_ids_from_properties(&props),
1891 vec!["atlas-1a2b", "atlas-3e4f"]
1892 );
1893 let mut edna = std::collections::BTreeMap::new();
1894 edna.insert("BLOCKER".into(), "prev-sibling".into());
1895 assert!(blocker_ids_from_properties(&edna).is_empty());
1896 }
1897
1898 #[test]
1899 fn effort_accepts_org_durations() {
1900 assert!(is_org_effort("1:30"));
1901 assert!(is_org_effort("2h"));
1902 assert!(is_org_effort("20d"));
1903 assert!(!is_org_effort("soon"));
1904 let mut props = std::collections::BTreeMap::new();
1905 props.insert("Effort".into(), "2h".into());
1906 assert_eq!(effort_from_properties(&props), Some("2h"));
1907 }
1908
1909 #[test]
1910 fn settle_moves_legal_type_and_tags_onto_the_heading() {
1911 let mut tags = Vec::new();
1912 let mut props = std::collections::BTreeMap::new();
1913 props.insert("TYPE".into(), "bug".into());
1914 props.insert("VISSUE_TAGS".into(), "perf,needs-review".into());
1915 settle_heading_classifiers(&mut tags, &mut props);
1916 assert_eq!(tags, vec!["bug", "perf"]);
1917 assert_eq!(
1918 props.get("VISSUE_TAGS").map(String::as_str),
1919 Some("needs-review")
1920 );
1921 assert_eq!(props.get("TYPE").map(String::as_str), Some("bug"));
1922 }
1923
1924 #[test]
1925 fn property_plus_appends() {
1926 assert_eq!(property_key_and_append("BLOCKED_BY+"), ("BLOCKED_BY", true));
1927 assert_eq!(property_key_and_append("ID"), ("ID", false));
1928 }
1929
1930 #[test]
1931 fn results_keywords_match_what_babel_writes() {
1932 assert!(is_results_keyword("#+RESULTS:"));
1933 assert!(is_results_keyword(" #+results:"));
1934 assert!(is_results_keyword("#+RESULTS[deadbeef]:"));
1935 assert!(is_results_keyword(
1936 "#+RESULTS[(2026-08-18 17:50) abcdef]: named"
1937 ));
1938 assert!(is_results_keyword("#+RESULTS: named"));
1939 assert!(!is_results_keyword("#+RESULTANT:"));
1940 assert!(!is_results_keyword("#+TODO: TODO"));
1941 }
1942
1943 #[test]
1944 fn babel_call_and_affiliated_keywords() {
1945 assert!(is_babel_call("#+CALL: plot(x=1) :results output"));
1946 assert!(is_babel_call("#+call: fn[:session]()"));
1947 assert!(!is_babel_call("#+CALLING:"));
1948 assert!(is_affiliated_keyword("#+NAME: plot"));
1949 assert!(is_affiliated_keyword("#+HEADER: :var x=1"));
1950 assert!(is_affiliated_keyword("#+ATTR_HTML: :width 40"));
1951 assert!(is_affiliated_keyword("#+TBLNAME: old"));
1952 assert!(!is_affiliated_keyword("#+TODO: TODO"));
1953 }
1954
1955 #[test]
1956 fn src_begin_splits_lang_switches_and_headers() {
1957 let head = parse_src_begin(" #+BEGIN_SRC python -n -r :results output :var x=1").unwrap();
1958 assert_eq!(head.lang, "python");
1959 assert_eq!(head.switches, "-n -r");
1960 assert_eq!(head.headers, ":results output :var x=1");
1961 assert_eq!(
1962 parse_header_args(head.headers),
1963 vec![
1964 ("results".into(), "output".into()),
1965 ("var".into(), "x=1".into())
1966 ]
1967 );
1968 }
1969
1970 #[test]
1971 fn noweb_and_inline_src_and_calls() {
1972 assert_eq!(
1973 noweb_refs("use <<setup>> and <<setup(n=1)>>"),
1974 vec!["setup", "setup(n=1)"]
1975 );
1976 assert_eq!(
1977 inline_src_spans("see src_python[:results raw]{print(1)} and src_elisp{(+ 1 2)}"),
1978 vec![
1979 ("python", ":results raw", "print(1)"),
1980 ("elisp", "", "(+ 1 2)")
1981 ]
1982 );
1983 assert_eq!(
1984 inline_call_names("then call_plot[:session](x=1) here"),
1985 vec!["plot"]
1986 );
1987 }
1988
1989 #[test]
1990 fn babel_results_hide_headlines_and_drawers() {
1991 let mut scan = OrgScan::new();
1992 assert!(!scan.observe("#+NAME: dump"));
1993 assert!(!scan.observe("prologue"));
1994 assert!(scan.observe("#+BEGIN_SRC python :results raw"));
1995 assert!(scan.observe("print('* TODO dumped')"));
1996 assert!(scan.observe("#+END_SRC"));
1997 assert!(!scan.inside());
1998 assert!(scan.observe("#+RESULTS:"));
1999 assert!(scan.observe("* TODO dumped"));
2000 assert!(scan.observe(":PROPERTIES:"));
2001 assert!(scan.observe(":ID: ghost-9999"));
2002 assert!(scan.observe(":END:"));
2003 assert!(scan.inside());
2004 assert!(!scan.observe("* TODO real"));
2005 assert!(!scan.inside());
2006 }
2007
2008 #[test]
2009 fn babel_results_table_and_fixed_width_and_drawer() {
2010 let mut scan = OrgScan::new();
2011 assert!(scan.observe("#+RESULTS:"));
2012 assert!(scan.observe("| a | b |"));
2013 assert!(scan.observe("|---+---|"));
2014 assert!(scan.observe("| 1 | 2 |"));
2015 assert!(!scan.observe("after the table"));
2016
2017 let mut scan = OrgScan::new();
2018 assert!(scan.observe("#+RESULTS:"));
2019 assert!(scan.observe(": 42"));
2020 assert!(!scan.observe("not fixed width"));
2021
2022 let mut scan = OrgScan::new();
2023 assert!(scan.observe("#+RESULTS:"));
2024 assert!(scan.observe(":RESULTS:"));
2025 assert!(scan.observe("* looks like a headline"));
2026 assert!(scan.observe(":END:"));
2027 assert!(!scan.observe("* TODO real"));
2028 }
2029}