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