Skip to main content

kb/
parser.rs

1//! Org-mode text → AST parser using hand-written recursive descent.
2//!
3//! Parses org syntax into the canonical [`Document`] AST. Covers every
4//! constructor that the generator produces so round-trip is well-defined.
5
6use tftio_org::ast::{
7    Block, Checkbox, Document, Inline, ListItem, ListType, LogEntry, PlanningEntry, TableCell, Tag,
8    Timestamp, Title,
9};
10
11/// Parse error with position context.
12#[derive(Debug, Clone)]
13pub struct ParseError {
14    /// Human-readable description of the parse failure.
15    pub message: String,
16}
17
18impl std::fmt::Display for ParseError {
19    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
20        write!(f, "{}", self.message)
21    }
22}
23
24impl std::error::Error for ParseError {}
25
26/// Parse an org-mode document string into a [`Document`].
27///
28/// # Errors
29///
30/// Returns `ParseError` if the input cannot be parsed.
31pub fn parse_document(input: &str) -> Result<Document, ParseError> {
32    parse_document_with_residue(input).map(|(doc, _residue)| doc)
33}
34
35/// Parse an org-mode document, also returning the content of input lines
36/// that no block claimed.
37///
38/// A file whose residue is empty lost no content during parsing, even if
39/// it does not round-trip byte-for-byte: every source line is represented
40/// in some block. Blank lines are never residue. Lines inside quote
41/// blocks are not tracked.
42///
43/// # Errors
44///
45/// Returns `ParseError` if the input cannot be parsed.
46pub fn parse_document_with_residue(input: &str) -> Result<(Document, Vec<String>), ParseError> {
47    let lines: Vec<&str> = input.lines().collect();
48    let mut residue = Vec::new();
49    let (blocks, _) = parse_blocks(&lines, 0, &mut residue)?;
50    Ok((Document { blocks }, residue))
51}
52
53type ParseResult<T> = Result<(T, usize), ParseError>;
54
55/// Parse a sequence of blocks starting at `pos`. Returns parsed blocks and new position.
56///
57/// Unrecognized non-blank lines are appended to `residue`.
58#[allow(
59    clippy::unnecessary_wraps,
60    reason = "mirrors the fallible `ParseResult` shape of the sibling `try_parse_*` combinators for uniform composition"
61)]
62fn parse_blocks(lines: &[&str], pos: usize, residue: &mut Vec<String>) -> ParseResult<Vec<Block>> {
63    let mut blocks = Vec::new();
64    let mut i = pos;
65    while i < lines.len() {
66        let Some(&line) = lines.get(i) else { break };
67        if line.is_empty() {
68            // Blank lines are represented explicitly for faithful spacing.
69            blocks.push(Block::BlankLine);
70            i += 1;
71            continue;
72        }
73
74        if let Some(Ok((block, next))) = try_parse_heading(lines, i, residue)
75            .or_else(|| try_parse_property_drawer(lines, i))
76            .or_else(|| try_parse_logbook_drawer(lines, i))
77            .or_else(|| try_parse_src_block(lines, i))
78            .or_else(|| try_parse_example_block(lines, i))
79            .or_else(|| try_parse_quote_block(lines, i))
80            .or_else(|| try_parse_list(lines, i))
81            .or_else(|| try_parse_table(lines, i))
82            .or_else(|| try_parse_planning(i, line))
83            .or_else(|| try_parse_comment(i, line))
84            .or_else(|| try_parse_keyword(i, line))
85            .or_else(|| try_parse_horizontal_rule(i, line))
86            .or_else(|| try_parse_paragraph(lines, i))
87        {
88            blocks.push(block);
89            i = next;
90        } else {
91            // Unrecognized line — record as residue (dropped content).
92            if !line.is_empty() {
93                residue.push(line.to_string());
94            }
95            i += 1;
96        }
97    }
98    Ok((blocks, i))
99}
100
101fn try_parse_heading(
102    lines: &[&str],
103    pos: usize,
104    residue: &mut Vec<String>,
105) -> Option<ParseResult<Block>> {
106    let line = *lines.get(pos)?;
107    if !line.starts_with('*') {
108        return None;
109    }
110
111    let raw_level = line.chars().take_while(|c| *c == '*').count();
112    // Require at least one space after stars for a valid heading
113    if raw_level >= line.len() || !line[raw_level..].starts_with(' ') {
114        return None;
115    }
116    let level: u8 = u8::try_from(raw_level.min(255)).unwrap_or(u8::MAX);
117    let rest = line[raw_level..].trim();
118
119    // Parse tags at end: "Title :tag1:tag2:"
120    let (title_str, tags) = rest.rfind(" :").map_or((rest, vec![]), |tag_start| {
121        let tag_part = &rest[tag_start + 1..];
122        if tag_part.starts_with(':') && tag_part.ends_with(':') && tag_part.len() > 2 {
123            let title = rest[..tag_start].trim();
124            let tags: Vec<Tag> = tag_part[1..tag_part.len() - 1]
125                .split(':')
126                .filter(|t| !t.is_empty())
127                .map(|t| Tag(t.to_string()))
128                .collect();
129            (title, tags)
130        } else {
131            (rest, vec![])
132        }
133    });
134
135    let title = Title(title_str.to_string());
136
137    // Collect children (blocks at higher indentation level)
138    let mut children = Vec::new();
139    let mut next = pos + 1;
140    while next < lines.len() && !lines.get(next).is_some_and(|l| l.starts_with('*')) {
141        // Gather child blocks that start on this or later lines up to the
142        // next blank-line-separated block or next heading.
143        let Some(&line) = lines.get(next) else { break };
144        if line.is_empty() {
145            children.push(Block::BlankLine);
146            next += 1;
147            continue;
148        }
149        // Try to parse the next item as a child block
150        let mut consumed = false;
151        for child_parser in &[
152            try_parse_property_drawer,
153            try_parse_logbook_drawer,
154            try_parse_src_block,
155            try_parse_example_block,
156            try_parse_quote_block,
157            try_parse_list,
158            try_parse_table,
159        ] {
160            if let Some(Ok((child_block, new_pos))) = child_parser(lines, next) {
161                children.push(child_block);
162                next = new_pos;
163                consumed = true;
164                break;
165            }
166        }
167        if !consumed {
168            // Try paragraph
169            if let Some(Ok((para, new_pos))) = try_parse_paragraph(lines, next) {
170                children.push(para);
171                next = new_pos;
172            } else {
173                // Unrecognized child line — record as residue.
174                if let Some(&child) = lines.get(next)
175                    && !child.is_empty()
176                {
177                    residue.push(child.to_string());
178                }
179                next += 1;
180            }
181        }
182    }
183
184    Some(Ok((
185        Block::Heading {
186            level,
187            title,
188            tags,
189            children,
190        },
191        next,
192    )))
193}
194
195fn try_parse_property_drawer(lines: &[&str], pos: usize) -> Option<ParseResult<Block>> {
196    if lines.get(pos)?.trim() != ":PROPERTIES:" {
197        return None;
198    }
199    let mut entries = Vec::new();
200    let mut i = pos + 1;
201    while i < lines.len() {
202        let Some(line) = lines.get(i).map(|l| l.trim()) else {
203            break;
204        };
205        if line == ":END:" {
206            return Some(Ok((Block::PropertyDrawer { entries }, i + 1)));
207        }
208        if let Some(stripped) = line.strip_prefix(':')
209            && let Some(colon_pos) = stripped.find(':')
210        {
211            let key = &stripped[..colon_pos];
212            // Value kept verbatim (leading padding included) so aligned
213            // drawers round-trip.
214            let value = &stripped[colon_pos + 1..];
215            entries.push((key.to_string(), value.to_string()));
216        }
217        i += 1;
218    }
219    Some(Ok((Block::PropertyDrawer { entries }, i)))
220}
221
222fn try_parse_logbook_drawer(lines: &[&str], pos: usize) -> Option<ParseResult<Block>> {
223    if lines.get(pos)?.trim() != ":LOGBOOK:" {
224        return None;
225    }
226    let mut entries = Vec::new();
227    let mut i = pos + 1;
228    while i < lines.len() {
229        let Some(line) = lines.get(i).map(|l| l.trim()) else {
230            break;
231        };
232        if line == ":END:" {
233            return Some(Ok((Block::LogbookDrawer { entries }, i + 1)));
234        }
235        // Parse "- <timestamp> note"
236        if let Some(rest) = line.strip_prefix("- ")
237            && rest.starts_with('<')
238            && let Some(close) = rest.find('>')
239        {
240            let ts = &rest[..=close];
241            let note = rest[close + 1..].trim();
242            entries.push(LogEntry {
243                timestamp: Timestamp(ts.to_string()),
244                note: note.to_string(),
245            });
246        }
247        i += 1;
248    }
249    Some(Ok((Block::LogbookDrawer { entries }, i)))
250}
251
252fn try_parse_src_block(lines: &[&str], pos: usize) -> Option<ParseResult<Block>> {
253    let line = lines.get(pos)?.trim();
254    if !line.starts_with("#+begin_src") {
255        return None;
256    }
257    let language = line.strip_prefix("#+begin_src")?.trim().to_string();
258
259    let mut i = pos + 1;
260    let mut content = String::new();
261    while i < lines.len() {
262        let Some(&cur) = lines.get(i) else { break };
263        if cur.trim() == "#+end_src" {
264            // Canonical form: non-empty src bodies always end with newline
265            if !content.is_empty() && !content.ends_with('\n') {
266                content.push('\n');
267            }
268            return Some(Ok((Block::SrcBlock { language, content }, i + 1)));
269        }
270        if !content.is_empty() {
271            content.push('\n');
272        }
273        content.push_str(cur);
274        i += 1;
275    }
276    // No end marker found — treat rest as content
277    Some(Ok((Block::SrcBlock { language, content }, i)))
278}
279
280fn try_parse_example_block(lines: &[&str], pos: usize) -> Option<ParseResult<Block>> {
281    if lines.get(pos)?.trim() != "#+begin_example" {
282        return None;
283    }
284    let mut i = pos + 1;
285    let mut content = String::new();
286    while i < lines.len() {
287        let Some(&cur) = lines.get(i) else { break };
288        if cur.trim() == "#+end_example" {
289            if !content.is_empty() && !content.ends_with('\n') {
290                content.push('\n');
291            }
292            return Some(Ok((Block::ExampleBlock { content }, i + 1)));
293        }
294        if !content.is_empty() {
295            content.push('\n');
296        }
297        content.push_str(cur);
298        i += 1;
299    }
300    // No end marker — treat the rest as content.
301    Some(Ok((Block::ExampleBlock { content }, i)))
302}
303
304fn try_parse_quote_block(lines: &[&str], pos: usize) -> Option<ParseResult<Block>> {
305    if lines.get(pos)?.trim() != "#+begin_quote" {
306        return None;
307    }
308    let mut i = pos + 1;
309    let mut child_lines = Vec::new();
310    while i < lines.len() {
311        let Some(&cur) = lines.get(i) else { break };
312        if cur.trim() == "#+end_quote" {
313            // Quote-block interiors are not residue-tracked.
314            let (children, _) = parse_blocks(&child_lines, 0, &mut Vec::new()).ok()?;
315            return Some(Ok((Block::QuoteBlock { children }, i + 1)));
316        }
317        child_lines.push(cur);
318        i += 1;
319    }
320    None
321}
322
323fn try_parse_list(lines: &[&str], pos: usize) -> Option<ParseResult<Block>> {
324    let (list_type, first_checkbox, first_rest) = match_bullet(lines.get(pos)?)?;
325
326    let mut items: Vec<ListItem> = Vec::new();
327    let mut cur_checkbox = first_checkbox;
328    let mut cur_inlines = parse_inlines(first_rest);
329    let mut i = pos + 1;
330
331    while i < lines.len() {
332        let Some(&line) = lines.get(i) else { break };
333        if line.is_empty() {
334            // A blank line ends the list.
335            break;
336        }
337        if let Some((_lt, checkbox, rest)) = match_bullet(line) {
338            // A column-0 bullet starts the next sibling item.
339            items.push(ListItem {
340                content: vec![Block::Paragraph {
341                    inlines: std::mem::take(&mut cur_inlines),
342                }],
343                checkbox: cur_checkbox,
344            });
345            cur_checkbox = checkbox;
346            cur_inlines = parse_inlines(rest);
347            i += 1;
348        } else if line.starts_with(' ') || line.starts_with('\t') {
349            // Indented continuation of the current item — including
350            // nested sub-bullets, kept verbatim as continuation text.
351            cur_inlines.push(Inline::LineBreak);
352            cur_inlines.extend(parse_inlines(line));
353            i += 1;
354        } else {
355            // A column-0 non-bullet line ends the list.
356            break;
357        }
358    }
359    items.push(ListItem {
360        content: vec![Block::Paragraph {
361            inlines: cur_inlines,
362        }],
363        checkbox: cur_checkbox,
364    });
365    Some(Ok((Block::List { list_type, items }, i)))
366}
367
368/// If `line` starts (column 0) with a list bullet, return the list type,
369/// checkbox state, and the content after the bullet and checkbox.
370fn match_bullet(line: &str) -> Option<(ListType, Checkbox, &str)> {
371    if let Some(rest) = line.strip_prefix("- ") {
372        let (checkbox, rest) = strip_checkbox(rest);
373        return Some((ListType::Unordered, checkbox, rest));
374    }
375    // Ordered: `N. ` for one or more digits.
376    let digits = line.chars().take_while(char::is_ascii_digit).count();
377    if digits > 0
378        && let Some(rest) = line[digits..].strip_prefix(". ")
379    {
380        let ordinal: u64 = line[..digits].parse().unwrap_or(1);
381        let (checkbox, rest) = strip_checkbox(rest);
382        return Some((ListType::Ordered(ordinal), checkbox, rest));
383    }
384    None
385}
386
387/// Strip a leading `[ ] ` / `[X] ` checkbox marker, if present.
388fn strip_checkbox(s: &str) -> (Checkbox, &str) {
389    s.strip_prefix("[X] ").map_or_else(
390        || {
391            s.strip_prefix("[ ] ")
392                .map_or((Checkbox::NoCheckbox, s), |r| (Checkbox::Unchecked, r))
393        },
394        |r| (Checkbox::Checked, r),
395    )
396}
397
398fn try_parse_table(lines: &[&str], pos: usize) -> Option<ParseResult<Block>> {
399    let first = lines.get(pos)?.trim();
400    if !first.starts_with('|') || !first.ends_with('|') {
401        return None;
402    }
403    let mut rows = Vec::new();
404    let mut i = pos;
405    while i < lines.len() {
406        let Some(line) = lines.get(i).map(|l| l.trim()) else {
407            break;
408        };
409        // A blank line ends the table.
410        if line.is_empty() {
411            break;
412        }
413        if line.len() < 2 || !line.starts_with('|') || !line.ends_with('|') {
414            break;
415        }
416        // Cells are kept verbatim, padding included — column alignment
417        // and separator rows (`|---+---|`) round-trip as cell content.
418        let cells: Vec<TableCell> = line[1..line.len() - 1]
419            .split('|')
420            .map(|c| TableCell {
421                inlines: parse_inlines(c),
422            })
423            .collect();
424        rows.push(cells);
425        i += 1;
426    }
427    if rows.is_empty() {
428        return None;
429    }
430    Some(Ok((Block::Table { rows }, i)))
431}
432
433fn try_parse_planning(pos: usize, line: &str) -> Option<ParseResult<Block>> {
434    let trimmed = line.trim();
435    if !trimmed.starts_with("SCHEDULED: ")
436        && !trimmed.starts_with("DEADLINE: ")
437        && !trimmed.starts_with("CLOSED: ")
438    {
439        return None;
440    }
441
442    let mut entries = Vec::new();
443    // Scan the line for keyword + timestamp pairs
444    let mut remaining = trimmed;
445    while !remaining.is_empty() {
446        if let Some(rest) = remaining.strip_prefix("SCHEDULED: ")
447            && let Some((ts, after)) = extract_timestamp(rest)
448        {
449            entries.push(PlanningEntry::Scheduled(Timestamp(ts)));
450            remaining = after;
451            continue;
452        }
453        if let Some(rest) = remaining.strip_prefix("DEADLINE: ")
454            && let Some((ts, after)) = extract_timestamp(rest)
455        {
456            entries.push(PlanningEntry::Deadline(Timestamp(ts)));
457            remaining = after;
458            continue;
459        }
460        if let Some(rest) = remaining.strip_prefix("CLOSED: ")
461            && let Some((ts, after)) = extract_timestamp(rest)
462        {
463            entries.push(PlanningEntry::Closed(Timestamp(ts)));
464            remaining = after;
465            continue;
466        }
467        break;
468    }
469
470    if entries.is_empty() {
471        return None;
472    }
473    Some(Ok((Block::Planning { entries }, pos + 1)))
474}
475
476/// Extract a timestamp like `<2026-04-30 Thu>` from the start of `s`.
477/// Returns the timestamp string and the remaining text.
478fn extract_timestamp(s: &str) -> Option<(String, &str)> {
479    let s = s.trim();
480    if !s.starts_with('<') {
481        return None;
482    }
483    let close = s.find('>')?;
484    let ts = s[..=close].to_string();
485    Some((ts, s[close + 1..].trim()))
486}
487
488fn try_parse_comment(pos: usize, line: &str) -> Option<ParseResult<Block>> {
489    let trimmed = line.trim();
490    trimmed.strip_prefix("# ").map(|text| {
491        Ok((
492            Block::Comment {
493                text: text.to_string(),
494            },
495            pos + 1,
496        ))
497    })
498}
499
500/// Parse a `#+NAME: value` keyword line.
501///
502/// `name` is the run of non-`:`, non-whitespace characters after `#+`;
503/// the character immediately after must be `:`. `value` is the verbatim
504/// remainder after that `:`, leading space included. Block delimiters
505/// such as `#+begin_src` have no `:` after the name and fall through.
506fn try_parse_keyword(pos: usize, line: &str) -> Option<ParseResult<Block>> {
507    let rest = line.strip_prefix("#+")?;
508    let name_len = rest
509        .find(|c: char| c == ':' || c.is_whitespace())
510        .unwrap_or(rest.len());
511    if name_len == 0 || rest.as_bytes().get(name_len) != Some(&b':') {
512        return None;
513    }
514    let name = rest[..name_len].to_string();
515    let value = rest[name_len + 1..].to_string();
516    Some(Ok((Block::Keyword { name, value }, pos + 1)))
517}
518
519fn try_parse_horizontal_rule(pos: usize, line: &str) -> Option<ParseResult<Block>> {
520    let trimmed = line.trim();
521    if trimmed == "-----" {
522        Some(Ok((Block::HorizontalRule, pos + 1)))
523    } else {
524        None
525    }
526}
527
528fn try_parse_paragraph(lines: &[&str], pos: usize) -> Option<ParseResult<Block>> {
529    if !is_paragraph_line(lines.get(pos)?) {
530        return None;
531    }
532    // Consume consecutive paragraph lines into one block, joining them
533    // with explicit `LineBreak`s so the source wrapping round-trips.
534    let mut inlines = Vec::new();
535    let mut i = pos;
536    while let Some(&line) = lines.get(i).filter(|l| is_paragraph_line(l)) {
537        if i > pos {
538            inlines.push(Inline::LineBreak);
539        }
540        inlines.extend(parse_inlines(line));
541        i += 1;
542    }
543    Some(Ok((Block::Paragraph { inlines }, i)))
544}
545
546/// Whether `line` can appear as paragraph content: neither blank nor the
547/// start of any other block kind.
548fn is_paragraph_line(line: &str) -> bool {
549    if line.is_empty() {
550        return false;
551    }
552    let trimmed = line.trim();
553    // Heading: one or more `*` followed by a space.
554    let stars = trimmed.chars().take_while(|c| *c == '*').count();
555    if stars > 0 && trimmed[stars..].starts_with(' ') {
556        return false;
557    }
558    if trimmed.starts_with("# ")
559        || trimmed.starts_with(":PROPERTIES:")
560        || trimmed.starts_with(":LOGBOOK:")
561        || trimmed.starts_with("#+begin_")
562        || trimmed.starts_with("SCHEDULED:")
563        || trimmed.starts_with("DEADLINE:")
564        || trimmed.starts_with("CLOSED:")
565        || trimmed == "-----"
566        || (trimmed.starts_with('|') && trimmed.ends_with('|'))
567    {
568        return false;
569    }
570    // A column-0 list bullet starts a list, not a paragraph. An indented
571    // bullet has no column-0 list to join, so it stays paragraph text.
572    if match_bullet(line).is_some() {
573        return false;
574    }
575    // Keyword line `#+name:`.
576    if let Some(rest) = trimmed.strip_prefix("#+") {
577        let name_len = rest
578            .find(|c: char| c == ':' || c.is_whitespace())
579            .unwrap_or(rest.len());
580        if name_len > 0 && rest.as_bytes().get(name_len) == Some(&b':') {
581            return false;
582        }
583    }
584    true
585}
586
587/// Parse inline formatting from a string.
588fn parse_inlines(input: &str) -> Vec<Inline> {
589    let mut inlines = Vec::new();
590    let mut pos = 0;
591    let chars: Vec<char> = input.chars().collect();
592
593    // `slice(a..b)` collects an in-range char range to a String. Every call
594    // site below derives its bounds from `find_closing` / `next_marker_or_end`
595    // / the loop guard, so the range is always valid; an empty fallback would
596    // only ever appear on a logic bug.
597    let slice =
598        |a: usize, b: usize| -> String { chars.get(a..b).unwrap_or_default().iter().collect() };
599    let slice_from = |a: usize| -> String { chars.get(a..).unwrap_or_default().iter().collect() };
600
601    while pos < chars.len() {
602        let Some(&c) = chars.get(pos) else { break };
603        match c {
604            '*' => {
605                if let Some(end) = find_closing(&chars, pos + 1, '*') {
606                    let inner = slice(pos + 1, end);
607                    inlines.push(Inline::Bold(parse_inlines(&inner)));
608                    pos = end + 1;
609                } else {
610                    // Treat as literal
611                    if let Some(end) = next_marker_or_end(&chars, pos) {
612                        inlines.push(Inline::Plain(slice(pos, end)));
613                        pos = end;
614                    } else {
615                        inlines.push(Inline::Plain(slice_from(pos)));
616                        pos = chars.len();
617                    }
618                }
619            }
620            '/' => {
621                if let Some(end) = find_closing(&chars, pos + 1, '/') {
622                    let inner = slice(pos + 1, end);
623                    inlines.push(Inline::Italic(parse_inlines(&inner)));
624                    pos = end + 1;
625                } else if let Some(end) = next_marker_or_end(&chars, pos) {
626                    inlines.push(Inline::Plain(slice(pos, end)));
627                    pos = end;
628                } else {
629                    inlines.push(Inline::Plain(slice_from(pos)));
630                    pos = chars.len();
631                }
632            }
633            '+' => {
634                if let Some(end) = find_closing(&chars, pos + 1, '+') {
635                    let inner = slice(pos + 1, end);
636                    inlines.push(Inline::Strikethrough(parse_inlines(&inner)));
637                    pos = end + 1;
638                } else if let Some(end) = next_marker_or_end(&chars, pos) {
639                    inlines.push(Inline::Plain(slice(pos, end)));
640                    pos = end;
641                } else {
642                    inlines.push(Inline::Plain(slice_from(pos)));
643                    pos = chars.len();
644                }
645            }
646            '=' => {
647                if let Some(end) = find_closing(&chars, pos + 1, '=') {
648                    let code = slice(pos + 1, end);
649                    inlines.push(Inline::InlineCode(code));
650                    pos = end + 1;
651                } else if let Some(end) = next_marker_or_end(&chars, pos) {
652                    inlines.push(Inline::Plain(slice(pos, end)));
653                    pos = end;
654                } else {
655                    inlines.push(Inline::Plain(slice_from(pos)));
656                    pos = chars.len();
657                }
658            }
659            '~' => {
660                if let Some(end) = find_closing(&chars, pos + 1, '~') {
661                    let verb = slice(pos + 1, end);
662                    inlines.push(Inline::Verbatim(verb));
663                    pos = end + 1;
664                } else if let Some(end) = next_marker_or_end(&chars, pos) {
665                    inlines.push(Inline::Plain(slice(pos, end)));
666                    pos = end;
667                } else {
668                    inlines.push(Inline::Plain(slice_from(pos)));
669                    pos = chars.len();
670                }
671            }
672            '[' => {
673                let (inline, next) = consume_bracket(&chars, pos);
674                inlines.push(inline);
675                pos = next;
676            }
677            _ => {
678                if let Some(end) = next_marker_or_end(&chars, pos) {
679                    inlines.push(Inline::Plain(slice(pos, end)));
680                    pos = end;
681                } else {
682                    inlines.push(Inline::Plain(slice_from(pos)));
683                    pos = chars.len();
684                }
685            }
686        }
687    }
688
689    // Merge adjacent Plain inlines
690    merge_adjacent_plain(&mut inlines);
691    inlines
692}
693
694/// Consume a `[`-run at `pos`: an org `[[target]]` / `[[target][desc]]`
695/// link, or — when the brackets do not form a well-shaped link — a plain
696/// literal run up to the next inline marker. Returns the inline to emit
697/// and the position after it. Never drops trailing text.
698fn consume_bracket(chars: &[char], pos: usize) -> (Inline, usize) {
699    // All ranges below are derived from `position(|c| c == ']')` matches or
700    // the loop-verified `pos`, so they are always in bounds; an empty
701    // fallback would only surface on a logic bug.
702    let collect =
703        |a: usize, b: usize| -> String { chars.get(a..b).unwrap_or_default().iter().collect() };
704    let collect_from = |a: usize| -> String { chars.get(a..).unwrap_or_default().iter().collect() };
705    let plain_to = |end: usize| Inline::Plain(collect(pos, end));
706    let plain_rest = || Inline::Plain(collect_from(pos));
707    let literal = || {
708        next_marker_or_end(chars, pos)
709            .map_or_else(|| (plain_rest(), chars.len()), |end| (plain_to(end), end))
710    };
711
712    // Not a `[[…` link opener — single bracket, literal.
713    if pos + 1 >= chars.len() || chars.get(pos + 1) != Some(&'[') {
714        return literal();
715    }
716    let start = pos + 2;
717    let Some(bracket_end) = chars
718        .get(start..)
719        .and_then(|rest| rest.iter().position(|&c| c == ']'))
720        .map(|p| start + p)
721    else {
722        return (plain_rest(), chars.len());
723    };
724
725    if bracket_end + 1 < chars.len() && chars.get(bracket_end + 1) == Some(&']') {
726        // [[target]]
727        let target = collect(start, bracket_end);
728        return (
729            Inline::Link {
730                target,
731                description: None,
732            },
733            bracket_end + 2,
734        );
735    }
736    if bracket_end + 1 >= chars.len() || chars.get(bracket_end + 1) != Some(&'[') {
737        // `[[…]` followed by something other than `]` or `[` — literal.
738        return literal();
739    }
740    // [[target][description]]
741    let target: String = collect(start, bracket_end);
742    let desc_start = bracket_end + 2;
743    match chars
744        .get(desc_start..)
745        .and_then(|rest| rest.iter().position(|&c| c == ']'))
746        .map(|p| desc_start + p)
747    {
748        Some(desc_end) if desc_end + 1 < chars.len() && chars.get(desc_end + 1) == Some(&']') => {
749            let description = collect(desc_start, desc_end);
750            (
751                Inline::Link {
752                    target,
753                    description: Some(description),
754                },
755                desc_end + 2,
756            )
757        }
758        // Malformed `[[target][…` — literal up to the unmatched `]`.
759        Some(desc_end) => (plain_to(desc_end + 1), desc_end + 1),
760        None => (plain_rest(), chars.len()),
761    }
762}
763
764fn find_closing(chars: &[char], start: usize, marker: char) -> Option<usize> {
765    for i in start..chars.len() {
766        let Some(&c) = chars.get(i) else { break };
767        if c == marker && (i + 1 == chars.len() || chars.get(i + 1) != Some(&marker)) {
768            return Some(i);
769        }
770    }
771    None
772}
773
774fn next_marker_or_end(chars: &[char], pos: usize) -> Option<usize> {
775    for i in pos..chars.len() {
776        let Some(&c) = chars.get(i) else { break };
777        if c == '*' || c == '/' || c == '+' || c == '=' || c == '~' || c == '[' {
778            if i == pos {
779                // Find the next different char
780                continue;
781            }
782            return Some(i);
783        }
784        if c == '[' && i + 1 < chars.len() && chars.get(i + 1) == Some(&'[') {
785            if i == pos {
786                continue;
787            }
788            return Some(i);
789        }
790    }
791    None
792}
793
794fn merge_adjacent_plain(inlines: &mut Vec<Inline>) {
795    let mut i = 0;
796    while i + 1 < inlines.len() {
797        if let (Some(Inline::Plain(a)), Some(Inline::Plain(b))) =
798            (inlines.get(i), inlines.get(i + 1))
799        {
800            let merged = format!("{a}{b}");
801            if let Some(slot) = inlines.get_mut(i) {
802                *slot = Inline::Plain(merged);
803            }
804            inlines.remove(i + 1);
805        } else {
806            i += 1;
807        }
808    }
809}
810
811#[cfg(test)]
812mod tests {
813    use super::*;
814
815    #[test]
816    fn parse_heading_level_1() {
817        let doc = parse_document("* Hello\n").unwrap();
818        assert_eq!(
819            doc.blocks[0],
820            Block::Heading {
821                level: 1,
822                title: Title("Hello".into()),
823                tags: vec![],
824                children: vec![]
825            }
826        );
827    }
828
829    #[test]
830    fn parse_heading_with_tags() {
831        let doc = parse_document("** Task :rust:kb:\n").unwrap();
832        if let Block::Heading {
833            level,
834            title,
835            tags,
836            children: _,
837        } = &doc.blocks[0]
838        {
839            assert_eq!(*level, 2);
840            assert_eq!(title.0, "Task");
841            assert_eq!(tags.len(), 2);
842            assert_eq!(tags[0].0, "rust");
843            assert_eq!(tags[1].0, "kb");
844        } else {
845            panic!("expected heading");
846        }
847    }
848
849    #[test]
850    fn parse_paragraph() {
851        let doc = parse_document("some text\n").unwrap();
852        assert_eq!(
853            doc.blocks[0],
854            Block::Paragraph {
855                inlines: vec![Inline::Plain("some text".into())]
856            }
857        );
858    }
859
860    #[test]
861    fn parse_bold() {
862        let doc = parse_document("*bold*\n").unwrap();
863        if let Block::Paragraph { inlines } = &doc.blocks[0] {
864            assert_eq!(inlines.len(), 1);
865            assert_eq!(inlines[0], Inline::Bold(vec![Inline::Plain("bold".into())]));
866        } else {
867            panic!("expected paragraph");
868        }
869    }
870
871    #[test]
872    fn parse_italic() {
873        let doc = parse_document("/italic/\n").unwrap();
874        if let Block::Paragraph { inlines } = &doc.blocks[0] {
875            assert_eq!(
876                inlines[0],
877                Inline::Italic(vec![Inline::Plain("italic".into())])
878            );
879        } else {
880            panic!("expected paragraph");
881        }
882    }
883
884    #[test]
885    fn parse_strikethrough() {
886        let doc = parse_document("+struck+\n").unwrap();
887        if let Block::Paragraph { inlines } = &doc.blocks[0] {
888            assert_eq!(
889                inlines[0],
890                Inline::Strikethrough(vec![Inline::Plain("struck".into())])
891            );
892        } else {
893            panic!("expected paragraph");
894        }
895    }
896
897    #[test]
898    fn parse_link_no_description() {
899        let doc = parse_document("[[https://example.com]]\n").unwrap();
900        if let Block::Paragraph { inlines } = &doc.blocks[0] {
901            assert_eq!(
902                inlines[0],
903                Inline::Link {
904                    target: "https://example.com".into(),
905                    description: None,
906                }
907            );
908        } else {
909            panic!("expected paragraph");
910        }
911    }
912
913    #[test]
914    fn parse_link_with_description() {
915        let doc = parse_document("[[https://example.com][example]]\n").unwrap();
916        if let Block::Paragraph { inlines } = &doc.blocks[0] {
917            assert_eq!(
918                inlines[0],
919                Inline::Link {
920                    target: "https://example.com".into(),
921                    description: Some("example".into()),
922                }
923            );
924        } else {
925            panic!("expected paragraph");
926        }
927    }
928
929    #[test]
930    fn parse_src_block() {
931        let input = "#+begin_src rust\nfn main() {}\n#+end_src\n";
932        let doc = parse_document(input).unwrap();
933        if let Block::SrcBlock { language, content } = &doc.blocks[0] {
934            assert_eq!(language, "rust");
935            assert_eq!(content, "fn main() {}\n");
936        } else {
937            panic!("expected src block");
938        }
939    }
940
941    #[test]
942    fn parse_example_block() {
943        let input = "#+begin_example\n$ ls\nfoo\n#+end_example\n";
944        let doc = parse_document(input).unwrap();
945        assert_eq!(
946            doc.blocks[0],
947            Block::ExampleBlock {
948                content: "$ ls\nfoo\n".into(),
949            }
950        );
951    }
952
953    #[test]
954    fn parse_property_drawer() {
955        let input = ":PROPERTIES:\n:ID: abc-123\n:END:\n";
956        let doc = parse_document(input).unwrap();
957        if let Block::PropertyDrawer { entries } = &doc.blocks[0] {
958            assert_eq!(entries.len(), 1);
959            assert_eq!(entries[0].0, "ID");
960            // Value kept verbatim, including the space after the key colon.
961            assert_eq!(entries[0].1, " abc-123");
962        } else {
963            panic!("expected property drawer");
964        }
965    }
966
967    #[test]
968    fn parse_list_unordered() {
969        let input = "- one\n- two\n";
970        let doc = parse_document(input).unwrap();
971        if let Block::List { list_type, items } = &doc.blocks[0] {
972            assert_eq!(*list_type, ListType::Unordered);
973            assert_eq!(items.len(), 2);
974        } else {
975            panic!("expected list");
976        }
977    }
978
979    #[test]
980    fn parse_comment() {
981        let doc = parse_document("# a comment\n").unwrap();
982        assert_eq!(
983            doc.blocks[0],
984            Block::Comment {
985                text: "a comment".into()
986            }
987        );
988    }
989
990    #[test]
991    fn parse_horizontal_rule() {
992        let doc = parse_document("-----\n").unwrap();
993        assert_eq!(doc.blocks[0], Block::HorizontalRule);
994    }
995
996    #[test]
997    fn parse_single_bracket_run_keeps_trailing_text() {
998        // A `[...]` that is not a `[[link]]` must not drop the rest of
999        // the line.
1000        let doc = parse_document("see [his] notes here\n").unwrap();
1001        assert_eq!(
1002            doc.blocks[0],
1003            Block::Paragraph {
1004                inlines: vec![Inline::Plain("see [his] notes here".into())],
1005            }
1006        );
1007    }
1008
1009    #[test]
1010    fn residue_empty_when_every_line_claimed() {
1011        let input = "* Heading\n\nA paragraph.\n";
1012        let (_, residue) = parse_document_with_residue(input).unwrap();
1013        assert!(
1014            residue.is_empty(),
1015            "no line should be unclaimed: {residue:?}"
1016        );
1017    }
1018
1019    #[test]
1020    fn residue_records_unparsable_block_marker() {
1021        // `#+begin_verse` is claimed by no block — kb models src, quote,
1022        // and example blocks but not verse blocks.
1023        let (_, residue) = parse_document_with_residue("#+begin_verse\n").unwrap();
1024        assert_eq!(residue, vec!["#+begin_verse".to_string()]);
1025    }
1026
1027    #[test]
1028    fn residue_records_unclaimed_line_under_heading() {
1029        let input = "* Heading\n#+begin_verse\n";
1030        let (_, residue) = parse_document_with_residue(input).unwrap();
1031        assert_eq!(residue, vec!["#+begin_verse".to_string()]);
1032    }
1033
1034    #[test]
1035    fn residue_excludes_blank_lines() {
1036        let (_, residue) = parse_document_with_residue("para\n\n\n").unwrap();
1037        assert!(
1038            residue.is_empty(),
1039            "blank lines are not residue: {residue:?}"
1040        );
1041    }
1042}