Skip to main content

vissue_core/
org.rs

1//! Org 9.8 syntax the tracker has to get right.
2//!
3//! An `issues.org` is an ordinary Org document. The verbs treat a top-level
4//! TODO heading as an issue; everything else in the file is still Org, and a
5//! construct Org would not treat as a headline or a property drawer must not
6//! become one here.
7//!
8//! The rules follow the Org 9.8 manual:
9//! <https://orgmode.org/manual/>.
10
11use crate::model::TODO_KEYWORDS;
12
13/// Keywords Org writes on the planning line, in the order Org writes them.
14pub const PLANNING_KEYS: &[&str] = &["CLOSED", "SCHEDULED", "DEADLINE"];
15
16/// Whether `c` may appear in an Org tag (`[[:alnum:]_@#%]+`).
17pub fn is_org_tag_char(c: char) -> bool {
18    c.is_alphanumeric() || matches!(c, '_' | '@' | '#' | '%')
19}
20
21/// A line Org reads as a headline: one or more stars at column 0, then a space.
22///
23/// Manual 2.1. A line that merely *looks* starred (`**bold**`) is not one.
24/// Leading whitespace takes the line off the left margin, so it is not one
25/// either.
26pub 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
31/// A level-one headline: `* ` at column 0. That is an issue site.
32pub fn is_top_level_headline(line: &str) -> bool {
33    line.starts_with("* ")
34}
35
36/// `:NAME:` alone on a line, which is how every drawer opens (manual 2.7).
37pub 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/// Nesting of greater blocks (`#+BEGIN_SRC` … `#+END_SRC`) and dynamic
46/// blocks (`#+BEGIN: clocktable` … `#+END:`).
47///
48/// Manual 2.8, 12.6, 16.2. Content inside a block is literal: a line that
49/// looks like a headline or a drawer is not one.
50#[derive(Debug, Default, Clone)]
51pub struct BlockNest {
52    depth: usize,
53}
54
55impl BlockNest {
56    /// Empty nest, at file (or heading) scope.
57    pub fn new() -> Self {
58        Self::default()
59    }
60
61    /// Whether a previous line opened a block that has not yet closed.
62    pub fn inside(&self) -> bool {
63        self.depth > 0
64    }
65
66    /// Observe `line`. Returns true when the line is part of a block,
67    /// including the `#+BEGIN` / `#+END` lines themselves.
68    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/// Walk that hides both greater/dynamic blocks and Org Babel result
83/// regions (manual 16).
84///
85/// vissue never evaluates a source block. It only has to recognise the
86/// syntax Babel writes, so a `#+RESULTS:` payload is not an issue and
87/// does not define an `:ID:`.
88#[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    /// Just saw `#+RESULTS:`; the next element is the payload.
99    Awaiting,
100    /// The payload is a greater or dynamic block.
101    ViaBlock,
102    Drawer,
103    Table,
104    FixedWidth,
105    List,
106    Headline {
107        stars: usize,
108    },
109}
110
111impl OrgScan {
112    /// Empty scan, at file (or heading) scope.
113    pub fn new() -> Self {
114        Self::default()
115    }
116
117    /// Whether the previous line left us inside a block or a results payload.
118    pub fn inside(&self) -> bool {
119        self.blocks.inside() || !matches!(self.results, ResultsState::Out)
120    }
121
122    /// Observe `line`. Returns true when the line is not document
123    /// structure: it belongs to a block or to a Babel results element.
124    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        // A file link, a scalar paragraph, or anything else Babel dumps as
231        // one element: this line is the payload.
232        self.results = ResultsState::Out;
233        true
234    }
235}
236
237/// `#+RESULTS:` / `#+RESULTS[hash]:` / `#+RESULTS: name` (manual 16.6).
238pub 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
257/// `#+CALL: name(...)` (manual 16.5 / Library of Babel).
258pub 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
265/// Affiliated keyword that binds to the next element (manual 16.3, org-element).
266pub 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/// The `#+BEGIN_SRC lang switches :headers` line (manual 16.2).
296#[derive(Debug, Clone, PartialEq, Eq)]
297pub struct SrcBlockHead<'a> {
298    /// Language token, `python` or `org`.
299    pub lang: &'a str,
300    /// Switches such as `-n -r`.
301    pub switches: &'a str,
302    /// Header arguments, starting at the first `:key`.
303    pub headers: &'a str,
304}
305
306/// Parse a source-block opening line. Other greater blocks yield `None`.
307pub 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
327/// `:key value` pairs from a header-args string (manual 16.3).
328pub 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    // Back up so we split on a char boundary.
363    while i > 0 && !s.is_char_boundary(i) {
364        i -= 1;
365    }
366    (s[..i].trim(), s[i..].trim_start())
367}
368
369/// Noweb references `<<name>>` / `<<name(args)>>` (manual 16.11).
370pub 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
387/// Inline `src_lang{body}` / `src_lang[headers]{body}` (manual 16.2).
388pub 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
425/// Inline `call_name(args)` / `call_name[hdr](args)` (manual 16.5).
426pub 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
529/// File-local TODO keywords from `#+TODO:` lines, plus the house set.
530///
531/// Manual 5.2.5. Fast-access keys (`TODO(t)`, `WAIT(w@)`) are stripped.
532/// Several `#+TODO:` lines accumulate. The house keywords stay recognised
533/// so a preamble that only lists a subset does not drop STARTED headings.
534pub fn todo_keywords_from_preamble(preamble: &str) -> Vec<String> {
535    todo_keywords_from_lines(&preamble.lines().collect::<Vec<_>>())
536}
537
538/// Same as [`todo_keywords_from_preamble`], from already-split lines.
539pub 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
562/// Tags from `#+FILETAGS:` (manual 6 / in-buffer settings).
563pub fn filetags_from_preamble(preamble: &str) -> Vec<String> {
564    tag_settings_from_preamble(preamble).filetags
565}
566
567/// A declared tag on `#+TAGS:`, with an optional fast-selection key.
568#[derive(Debug, Clone, PartialEq, Eq)]
569pub struct TagSpec {
570    /// Tag text, Org's `[[:alnum:]_@#%]+` or a `{regex}` group member.
571    pub name: String,
572    /// Fast tag selection key (`TAG(k)`).
573    pub key: Option<char>,
574}
575
576/// File-level tag, export, and publish settings (manual 6, 13.2, 17.8).
577#[derive(Debug, Clone, PartialEq, Eq)]
578pub struct TagSettings {
579    /// Tags every heading inherits, as if from a hypothetical level 0.
580    pub filetags: Vec<String>,
581    /// Tags declared on `#+TAGS:` lines, in file order.
582    pub declared: Vec<TagSpec>,
583    /// Mutually exclusive groups `{ a b }`.
584    pub exclusive: Vec<Vec<String>>,
585    /// Group tag then members: `[ GTD : Control Persp ]`.
586    pub hierarchies: Vec<(String, Vec<String>)>,
587    /// `#+SELECT_TAGS:`; Org's default is `export`.
588    pub select_tags: Vec<String>,
589    /// `#+EXCLUDE_TAGS:`; Org's default is `noexport`.
590    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    /// Own tags plus inherited FILETAGS, which is Org's ALLTAGS for a
608    /// level-one heading (manual 6.1). FILETAGS are not copied onto the
609    /// heading on write.
610    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    /// Whether `needle` matches an own tag, an inherited FILETAGS tag, or
621    /// a group tag whose members the heading carries (manual 6.3).
622    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    /// Whether this heading's own tags exclude it from Org export.
646    ///
647    /// FILETAGS `:noexport:` is a file-level publish signal and does not
648    /// hide the heading from a vissue mirror. A heading tagged `noexport`
649    /// (or another `#+EXCLUDE_TAGS:` token) is dropped. `noexport` wins
650    /// over `export` (manual 13.2).
651    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
660/// Parse `#+FILETAGS:`, `#+TAGS:`, `#+SELECT_TAGS:`, `#+EXCLUDE_TAGS:`.
661pub 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
828/// House `#+TAGS:` lines: types are mutually exclusive; the rest are loose.
829pub 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
834/// House `#+PRIORITIES:`: highest `A`, lowest `C`, default `C`.
835///
836/// Org's own default cookie is `B`. The tracker defaults to `C` so an
837/// unprioritised heading is the lowest cookie, not the middle one.
838pub const HOUSE_PRIORITIES_LINE: &str = "#+PRIORITIES: A C C";
839
840/// On-disk `issues.org` contract. Independent of the crate version and of
841/// the control-socket protocol.
842///
843/// 1 is the house Org shape: `#+CATEGORY:`, `#+FILETAGS:` with `noexport`,
844/// the type `#+TAGS:` group, `#+PRIORITIES: A C C`, `#+SELECT_TAGS:` /
845/// `#+EXCLUDE_TAGS:`, type as a heading tag, `:BLOCKED_BY:` as the graph,
846/// and `:BLOCKER:` as org-edna (read, never minted).
847pub const PROTOCOL_VERSION: u32 = 1;
848
849/// In-buffer keyword that carries [`PROTOCOL_VERSION`].
850pub const PROTOCOL_KEYWORD: &str = "VISSUE";
851
852/// Protocol integer from `#+VISSUE:`, when the line parses.
853pub 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/// File-local priority cookie range (`#+PRIORITIES: highest lowest default`).
882///
883/// Org requires the highest cookie to have a lower ASCII value than the
884/// lowest (`A` before `C`). The third token is the default when a heading
885/// has no `[#X]`.
886#[derive(Debug, Clone, Copy, PartialEq, Eq)]
887pub struct PrioritySpec {
888    /// Highest priority cookie (`A` in the house file).
889    pub highest: char,
890    /// Lowest priority cookie (`C` in the house file).
891    pub lowest: char,
892    /// Cookie written when the heading has none.
893    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    /// Whether `cookie` sits in this file's range, inclusive.
908    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
918/// `#+PRIORITIES:` from the preamble, or `A C C`.
919pub 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
942/// An org-gcal event id (`<event>/<calendar>`), not an org-id / vissue id.
943pub 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
954/// Org treats a non-empty property other than `nil` / `0` as true.
955pub 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
965/// In-buffer settings from `#+SETUPFILE:` plus the file's own preamble.
966///
967/// Local files only. A URL is left unread. Cycles and a missing file are
968/// skipped so a tracker still parses.
969pub 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
1026/// Whether the preamble already carries `#+NAME:`.
1027pub 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
1033/// Insert the house in-buffer settings a hand-started file never grew.
1034///
1035/// Org takes the category from the file name otherwise, and every
1036/// project's file is `issues.org`. A missing `#+TAGS:` means Emacs
1037/// fast-tag selection has no type group. `#+FILETAGS:` includes
1038/// `noexport` so a vault publish project skips the tracker (manual 13.2,
1039/// 14). A FILETAGS line that already exists but has no `noexport` gets
1040/// that tag appended.
1041pub 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
1125/// Move classifiers Org can hold onto the heading: a legal `:TYPE:` and
1126/// any legal token in `:VISSUE_TAGS:`. Hyphenated leftovers stay in the
1127/// property. `:TYPE:` itself is kept so export and `--type` filters still
1128/// read it.
1129pub 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
1167/// Org specials that are computed. Writing them in a drawer does not
1168/// set them; Org reads the headline, the planning line, or the clock.
1169pub 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
1183/// Org specials a heading may set. `CATEGORY` and `ARCHIVE` are the
1184/// ones the agenda actually honours from the drawer.
1185pub const SETTABLE_SPECIALS: &[&str] = &[
1186    "ARCHIVE",
1187    "CATEGORY",
1188    "COLUMNS",
1189    "COOKIE_DATA",
1190    "LOGGING",
1191    "ORDERED",
1192    "STYLE",
1193];
1194
1195/// Words org-edna and org-depend put in `:BLOCKER:` / `:TRIGGER:`.
1196const 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
1222/// Split a BLOCKED_BY-style id list: commas and whitespace both separate.
1223pub 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
1231/// Whether a `:BLOCKER:` value is org-edna / org-depend syntax, not a
1232/// list of issue ids. GNU ELPA org-edna is the maintained package;
1233/// org-depend in org-contrib is the older one. Both own this name.
1234pub 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
1247/// Issue ids mentioned in an org-edna `ids(...)` / `id(...)` form.
1248pub 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
1267/// Issue ids mentioned in an org-edna `ids(...)` / `id(...)` form.
1268pub 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
1275/// Every blocker id a heading declares: `:BLOCKED_BY:`, a typo
1276/// `:BLOCKEDBY:`, a `:BLOCKER:` that is just ids, and `ids(...)` inside
1277/// an org-edna form.
1278pub 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
1306/// Effort value Org's column view and agenda effort filter read.
1307/// `org-effort-property` defaults to `Effort`.
1308pub 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
1318/// A duration Org accepts for Effort: `1:30`, `2h`, `3d`, `0:10`.
1319pub 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/// The pieces Org puts on a headline after the stars (manual 2.1, 5.1, 5.4).
1348#[derive(Debug, Clone, PartialEq, Eq)]
1349pub struct HeadlineBits<'a> {
1350    /// TODO keyword, when the first word is one of `keywords`.
1351    pub keyword: Option<&'a str>,
1352    /// Priority cookie character, when `[#X]` follows the keyword.
1353    pub priority: Option<char>,
1354    /// Whether the heading carries the `COMMENT` keyword (manual 13.6).
1355    pub commented: bool,
1356    /// Title text, including a trailing tag run and statistics cookies.
1357    pub rest: &'a str,
1358}
1359
1360/// Split the text after `* ` into keyword, priority, COMMENT, and title.
1361pub 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
1406/// A top-level heading that is an issue: recognised TODO keyword, not COMMENT.
1407pub 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
1415/// Split a leading `[#A]` cookie off a heading. Any other shape yields `None`.
1416pub 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
1427/// Split trailing statistics cookies (`[2/5]`, `[33%]`) off a title.
1428///
1429/// Manual 5.5. Cookies sit after the title and before the tag run.
1430pub 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
1471/// Consume one Org timestamp or timestamp range at the start of `s`.
1472///
1473/// Manual 8.1: active `<>`, inactive `[]`, diary sexps `<%%(...)>`,
1474/// time ranges on one stamp, and ranges of two stamps joined by `--`.
1475/// Repeaters (`+1w`, `++1w`, `.+1w`) and warnings (`-2d`, `--2d`) live
1476/// inside the brackets, so the first closing delimiter is enough.
1477pub 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
1504/// Read an Org planning line into `KEY -> timestamp` pairs.
1505///
1506/// Org packs several onto one line. A line holding anything else is not a
1507/// planning line at all, so a body sentence that opens on `DEADLINE:` stays
1508/// body.
1509pub 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
1529/// Whether `trimmed` opens a planning line (any planning key and a colon).
1530pub 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
1536/// Ids named by Org links in `body` that also sit in `known_ids`.
1537///
1538/// Manual 4.1 / 4.4: `[[id:foo]]`, `[[id:foo][desc]]`, `<id:foo>`, and a
1539/// bare `id:foo`. `[[foo]]` and `[[file:x.org::foo]]` still resolve when
1540/// the target or the search fragment is a known id.
1541pub 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
1594/// Property key as written, with a trailing `+` (append) stripped.
1595///
1596/// Manual 7.1: `:var+: value` appends to `:var:`.
1597pub 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}