krz/orgo

Lightning fast org-mode static site generator.

clone: git clone https://gitbay.org/krz/orgo.git

v0.3.0: src/parser.rs · raw

  1//! PARSE stage (spec §2.1, §3.1): bytes → tokens → org element tree.
  2//!
  3//! Hand-written recursive descent, deliberately two-tier (spec §3.1):
  4//! 1. [`line_lexer`] — cheap first pass classifying each line, context-free.
  5//! 2. [`build_document`] — recursive descent over the line stream into `Section`s/`Element`s.
  6//! 3. [`inline`] — scans an element's text runs into `Vec<Object>`, implementing
  7//!    org's emphasis pre/post-char rules explicitly.
  8//!
  9//! PARSE is a pure function of a single file's bytes (spec §2.1): it never depends on
 10//! another file, which is what makes content-hash caching sound.
 11//!
 12//! v0.1 scope (the CORE subset): headings + nesting, property drawers on headings,
 13//! paragraphs, plain lists (unordered + ordered) with checkboxes, source blocks, and
 14//! inline markup (bold/italic/underline/strike/verbatim/code, links, bare URLs).
 15//! Out of scope and left graceful (parsed-and-ignored, never crashing): tables,
 16//! footnotes, timestamps, TODO keywords, non-SRC blocks (kept verbatim as example
 17//! blocks), generic drawers other than PROPERTIES.
 18
 19use camino::Utf8Path;
 20
 21use crate::model::{
 22    BlockParams, Bullet, Checkbox, ContentHash, Document, Element, Heading, Keywords, Link,
 23    LinkTarget, List, ListItem, ListKind, Object, Properties, Section, Table, TableRow,
 24};
 25
 26#[derive(Debug, thiserror::Error)]
 27pub enum ParseError {
 28    #[error("parse error at line {line}: {message}")]
 29    At { line: usize, message: String },
 30}
 31
 32/// Classified lines produced by the first pass (spec §3.1).
 33#[derive(Debug, Clone, PartialEq, Eq)]
 34pub enum Line {
 35    Heading,
 36    BlockBegin { kind: String },
 37    BlockEnd,
 38    ListItem,
 39    TableRow,
 40    Keyword,
 41    DrawerBegin,
 42    DrawerEnd,
 43    Rule,
 44    Blank,
 45    Text,
 46}
 47
 48/// First pass: classify each raw line. Context-free per line.
 49pub fn line_lexer(source: &str) -> Vec<Line> {
 50    source.lines().map(classify_line).collect()
 51}
 52
 53fn classify_line(line: &str) -> Line {
 54    if line.trim().is_empty() {
 55        return Line::Blank;
 56    }
 57    if heading_level(line).is_some() {
 58        return Line::Heading;
 59    }
 60    let t = line.trim_start();
 61    let upper = t.to_ascii_uppercase();
 62    if let Some(rest) = upper.strip_prefix("#+BEGIN_") {
 63        let kind = rest.split_whitespace().next().unwrap_or("").to_string();
 64        return Line::BlockBegin { kind };
 65    }
 66    if upper.starts_with("#+END_") {
 67        return Line::BlockEnd;
 68    }
 69    if keyword_kv(line).is_some() {
 70        return Line::Keyword;
 71    }
 72    if is_rule(line) {
 73        return Line::Rule;
 74    }
 75    if t.eq_ignore_ascii_case(":END:") {
 76        return Line::DrawerEnd;
 77    }
 78    if is_drawer_begin(t) {
 79        return Line::DrawerBegin;
 80    }
 81    if is_list_item(t).is_some() {
 82        return Line::ListItem;
 83    }
 84    if t.starts_with('|') {
 85        return Line::TableRow;
 86    }
 87    Line::Text
 88}
 89
 90/// blake3 of raw source bytes — the content hash that drives re-parse decisions (spec §4.1).
 91pub fn content_hash(bytes: &[u8]) -> ContentHash {
 92    ContentHash(*blake3::hash(bytes).as_bytes())
 93}
 94
 95/// Parse one source file into a [`Document`]. Pure over `(path, source)`.
 96pub fn parse(path: &Utf8Path, source: &str) -> Result<Document, ParseError> {
 97    let content_hash = content_hash(source.as_bytes());
 98    let lines: Vec<&str> = source.lines().collect();
 99    let classes = line_lexer(source);
100
101    let mut keywords = Keywords::default();
102    let mut root = Section {
103        heading: None,
104        content: Vec::new(),
105        children: Vec::new(),
106    };
107
108    let heading_idxs: Vec<usize> = classes
109        .iter()
110        .enumerate()
111        .filter(|(_, c)| **c == Line::Heading)
112        .map(|(i, _)| i)
113        .collect();
114    let first = heading_idxs.first().copied().unwrap_or(lines.len());
115
116    // Preamble: document-level keywords are lifted into `keywords`; the remaining
117    // lines become the root section's block content.
118    {
119        let mut body: Vec<&str> = Vec::new();
120        for (l, c) in lines[..first].iter().zip(&classes[..first]) {
121            if *c == Line::Keyword {
122                if let Some((k, v)) = keyword_kv(l) {
123                    keywords.entries.push((k, v));
124                }
125            } else {
126                body.push(l);
127            }
128        }
129        root.content = parse_elements(&body);
130    }
131
132    // Each heading segment runs from its own line up to (but excluding) the next heading.
133    let mut flat: Vec<(u8, Section)> = Vec::new();
134    for (k, &h_idx) in heading_idxs.iter().enumerate() {
135        let end = heading_idxs.get(k + 1).copied().unwrap_or(lines.len());
136        let heading = parse_heading(lines[h_idx]);
137        let level = heading.level;
138        let (heading, content) = parse_section_body(heading, &lines[h_idx + 1..end]);
139        flat.push((
140            level,
141            Section {
142                heading: Some(heading),
143                content,
144                children: Vec::new(),
145            },
146        ));
147    }
148
149    let mut pos = 0;
150    root.children = build_children(&mut flat, &mut pos, 0);
151
152    Ok(Document {
153        source_path: path.to_owned(),
154        content_hash,
155        keywords,
156        root,
157    })
158}
159
160/// Fold the flat `(level, section)` list into org's nested hierarchy by level.
161fn build_children(flat: &mut [(u8, Section)], pos: &mut usize, parent_level: u8) -> Vec<Section> {
162    let mut children = Vec::new();
163    while *pos < flat.len() {
164        let level = flat[*pos].0;
165        if level <= parent_level {
166            break;
167        }
168        let mut section = std::mem::replace(&mut flat[*pos].1, empty_section());
169        *pos += 1;
170        section.children = build_children(flat, pos, level);
171        children.push(section);
172    }
173    children
174}
175
176fn empty_section() -> Section {
177    Section {
178        heading: None,
179        content: Vec::new(),
180        children: Vec::new(),
181    }
182}
183
184/// Second-tier: scan an element's text into inline objects, applying org's
185/// pre/post-char emphasis rules (spec §3.1, R3 — the highest-divergence area).
186pub fn inline(text: &str) -> Vec<Object> {
187    let chars: Vec<char> = text.chars().collect();
188    parse_inline_run(&chars)
189}
190
191// ---------------------------------------------------------------------------
192// Headings
193// ---------------------------------------------------------------------------
194
195/// `*`-prefixed heading depth, or `None` if the line is not a heading.
196fn heading_level(line: &str) -> Option<u8> {
197    if !line.starts_with('*') {
198        return None;
199    }
200    let stars = line.chars().take_while(|c| *c == '*').count();
201    let after = &line[stars..];
202    if after.starts_with(' ') || after.is_empty() {
203        Some(stars.min(u8::MAX as usize) as u8)
204    } else {
205        None
206    }
207}
208
209fn parse_heading(line: &str) -> Heading {
210    let level = heading_level(line).unwrap_or(1);
211    let rest = line[level as usize..].trim();
212    let (title_str, tags) = split_tags(rest);
213    Heading {
214        level,
215        todo: None,     // TODO keywords: out of scope for v0.1.
216        priority: None, // priorities: out of scope for v0.1.
217        title: inline(title_str.trim()),
218        tags,
219        properties: Properties::default(),
220        id: None,
221        custom_id: None,
222    }
223}
224
225/// Split a trailing `:tag1:tag2:` cluster off the heading text.
226fn split_tags(rest: &str) -> (&str, Vec<String>) {
227    let trimmed = rest.trim_end();
228    if !trimmed.ends_with(':') {
229        return (rest, Vec::new());
230    }
231    let start = match trimmed.rfind(char::is_whitespace) {
232        Some(i) => i + 1,
233        None => 0,
234    };
235    let candidate = &trimmed[start..];
236    if is_tag_cluster(candidate) {
237        let tags = candidate
238            .split(':')
239            .filter(|s| !s.is_empty())
240            .map(|s| s.to_string())
241            .collect();
242        (&trimmed[..start], tags)
243    } else {
244        (rest, Vec::new())
245    }
246}
247
248/// A `:a:b:c:` cluster: colon-delimited, non-empty tag names, colon-bounded.
249fn is_tag_cluster(s: &str) -> bool {
250    if !s.starts_with(':') || !s.ends_with(':') || s.len() < 3 {
251        return false;
252    }
253    let inner = &s[1..s.len() - 1];
254    !inner.is_empty()
255        && inner.split(':').all(|part| {
256            !part.is_empty()
257                && part
258                    .chars()
259                    .all(|c| c.is_alphanumeric() || matches!(c, '_' | '@' | '#' | '%'))
260        })
261}
262
263// ---------------------------------------------------------------------------
264// Section body: property drawer + block content
265// ---------------------------------------------------------------------------
266
267fn parse_section_body(mut heading: Heading, body: &[&str]) -> (Heading, Vec<Element>) {
268    let mut idx = 0;
269    while idx < body.len() && body[idx].trim().is_empty() {
270        idx += 1;
271    }
272    if idx < body.len() && body[idx].trim().eq_ignore_ascii_case(":PROPERTIES:") {
273        idx += 1;
274        while idx < body.len() {
275            let t = body[idx].trim();
276            if t.eq_ignore_ascii_case(":END:") {
277                idx += 1;
278                break;
279            }
280            if let Some((k, v)) = parse_property(t) {
281                if k.eq_ignore_ascii_case("CUSTOM_ID") {
282                    heading.custom_id = Some(v.clone());
283                } else if k.eq_ignore_ascii_case("ID") {
284                    heading.id = Some(v.clone());
285                }
286                heading.properties.entries.push((k, v));
287            }
288            idx += 1;
289        }
290    }
291    let content = parse_elements(&body[idx..]);
292    (heading, content)
293}
294
295/// `:KEY: value` inside a drawer.
296fn parse_property(line: &str) -> Option<(String, String)> {
297    let line = line.trim();
298    let line = line.strip_prefix(':')?;
299    let end = line.find(':')?;
300    let key = line[..end].trim().to_string();
301    if key.is_empty() {
302        return None;
303    }
304    let value = line[end + 1..].trim().to_string();
305    Some((key, value))
306}
307
308// ---------------------------------------------------------------------------
309// Block-level element builder
310// ---------------------------------------------------------------------------
311
312fn parse_elements(lines: &[&str]) -> Vec<Element> {
313    let mut out = Vec::new();
314    let mut i = 0;
315    while i < lines.len() {
316        let line = lines[i];
317        if line.trim().is_empty() {
318            i += 1;
319            continue;
320        }
321        if let Some((kind, after)) = block_begin(line) {
322            let mut j = i + 1;
323            let mut inner = Vec::new();
324            while j < lines.len() && !is_block_end(lines[j]) {
325                inner.push(lines[j]);
326                j += 1;
327            }
328            let code = inner.join("\n");
329            if kind.eq_ignore_ascii_case("SRC") {
330                let (lang, params) = parse_src_header(&after);
331                out.push(Element::SrcBlock { lang, params, code });
332            } else {
333                // Non-SRC blocks (quote/example/center/export) are kept verbatim for
334                // v0.1 rather than richly modeled — see module scope note.
335                out.push(Element::ExampleBlock(code));
336            }
337            i = if j < lines.len() { j + 1 } else { j };
338            continue;
339        }
340        if is_rule(line) {
341            out.push(Element::HorizontalRule);
342            i += 1;
343            continue;
344        }
345        if let Some((key, value)) = keyword_kv(line) {
346            out.push(Element::Keyword { key, value });
347            i += 1;
348            continue;
349        }
350        if line.trim_start().starts_with('|') {
351            let (table, next) = parse_table(lines, i);
352            out.push(Element::Table(table));
353            i = next;
354            continue;
355        }
356        if let Some((label, first_rest)) = footnote_def_label(line) {
357            let (def, next) = parse_footnote_def(lines, i, label, first_rest);
358            out.push(def);
359            i = next;
360            continue;
361        }
362        if is_list_item(line.trim_start()).is_some() {
363            let (list, next) = parse_list(lines, i);
364            out.push(Element::List(list));
365            i = next;
366            continue;
367        }
368        // Paragraph: gather consecutive soft-wrapped text lines.
369        let mut para = Vec::new();
370        while i < lines.len() {
371            let l = lines[i];
372            if l.trim().is_empty() || is_structural(l) {
373                break;
374            }
375            para.push(l.trim());
376            i += 1;
377        }
378        if !para.is_empty() {
379            out.push(Element::Paragraph(inline(&para.join(" "))));
380        }
381    }
382    out
383}
384
385/// Is this line the start of a non-paragraph construct?
386fn is_structural(line: &str) -> bool {
387    let t = line.trim_start();
388    block_begin(line).is_some()
389        || is_block_end(line)
390        || is_rule(line)
391        || keyword_kv(line).is_some()
392        || is_list_item(t).is_some()
393        || t.starts_with('|')
394        || footnote_def_label(line).is_some()
395        || heading_level(line).is_some()
396}
397
398// ---------------------------------------------------------------------------
399// Tables (spec §1 IN; `#+TBLFM:` formulas are parse-and-ignored via keyword_kv)
400// ---------------------------------------------------------------------------
401
402/// Consume a run of consecutive `|`-prefixed lines into a [`Table`]. Rule rows
403/// (`|---+---|`) are preserved as [`TableRow::Rule`] so the renderer can locate the
404/// header band.
405fn parse_table(lines: &[&str], start: usize) -> (Table, usize) {
406    let mut rows = Vec::new();
407    let mut i = start;
408    while i < lines.len() {
409        let t = lines[i].trim_start();
410        if !t.starts_with('|') {
411            break;
412        }
413        if is_table_rule(t) {
414            rows.push(TableRow::Rule);
415        } else {
416            rows.push(TableRow::Cells(parse_table_cells(t)));
417        }
418        i += 1;
419    }
420    (Table { rows }, i)
421}
422
423/// A rule row: only `|`, `-`, `+`, whitespace, and at least one `-`.
424fn is_table_rule(t: &str) -> bool {
425    t.starts_with('|')
426        && t.contains('-')
427        && t.chars().all(|c| matches!(c, '|' | '-' | '+' | ' '))
428}
429
430fn parse_table_cells(t: &str) -> Vec<Vec<Object>> {
431    let inner = t.trim().trim_start_matches('|').trim_end_matches('|');
432    inner.split('|').map(|cell| inline(cell.trim())).collect()
433}
434
435// ---------------------------------------------------------------------------
436// Footnote definitions (spec §1 IN; inline refs handled in the inline tokenizer)
437// ---------------------------------------------------------------------------
438
439/// A footnote *definition* line: `[fn:LABEL] text...`. Returns the label and the
440/// remainder on the same line. `[fn:LABEL:inline]` (a colon inside the label span) is
441/// an inline reference, not a definition, so it is rejected here.
442fn footnote_def_label(line: &str) -> Option<(String, String)> {
443    let t = line.trim_start();
444    let r = t.strip_prefix("[fn:")?;
445    let end = r.find(']')?;
446    let label = &r[..end];
447    if label.is_empty() || label.contains(':') {
448        return None;
449    }
450    Some((label.to_string(), r[end + 1..].trim_start().to_string()))
451}
452
453/// Gather a footnote definition's content: the remainder of its opening line plus
454/// following continuation lines up to the next blank/structural/definition line.
455fn parse_footnote_def(
456    lines: &[&str],
457    start: usize,
458    label: String,
459    first_rest: String,
460) -> (Element, usize) {
461    let mut parts: Vec<String> = Vec::new();
462    if !first_rest.is_empty() {
463        parts.push(first_rest);
464    }
465    let mut i = start + 1;
466    while i < lines.len() {
467        let l = lines[i];
468        if l.trim().is_empty() || is_structural(l) {
469            break;
470        }
471        parts.push(l.trim().to_string());
472        i += 1;
473    }
474    let content = if parts.is_empty() {
475        Vec::new()
476    } else {
477        vec![Element::Paragraph(inline(&parts.join(" ")))]
478    };
479    (Element::FootnoteDefinition { label, content }, i)
480}
481
482fn parse_list(lines: &[&str], start: usize) -> (List, usize) {
483    let kind = match is_list_item(lines[start].trim_start()) {
484        Some(Bullet::Ordered(_)) => ListKind::Ordered,
485        _ => ListKind::Unordered,
486    };
487    let mut items = Vec::new();
488    let mut i = start;
489    while i < lines.len() {
490        let t = lines[i].trim_start();
491        let bullet = match is_list_item(t) {
492            Some(b) => b,
493            None => break,
494        };
495        let item_kind = match bullet {
496            Bullet::Ordered(_) => ListKind::Ordered,
497            _ => ListKind::Unordered,
498        };
499        if item_kind != kind {
500            break;
501        }
502        let rest = item_body(t, &bullet);
503        let (checkbox, text) = split_checkbox(rest);
504        items.push(ListItem {
505            bullet,
506            checkbox,
507            term: None, // description lists: out of scope for v0.1.
508            content: vec![Element::Paragraph(inline(text.trim()))],
509        });
510        i += 1;
511    }
512    (List { kind, items }, i)
513}
514
515/// Text of a list item after its bullet marker.
516fn item_body<'a>(item: &'a str, bullet: &Bullet) -> &'a str {
517    match bullet {
518        Bullet::Dash | Bullet::Plus => item[1..].trim_start(),
519        Bullet::Ordered(_) => {
520            // Skip digits then the `.`/`)` terminator.
521            let after_digits = item.trim_start_matches(|c: char| c.is_ascii_digit());
522            after_digits
523                .strip_prefix('.')
524                .or_else(|| after_digits.strip_prefix(')'))
525                .unwrap_or(after_digits)
526                .trim_start()
527        }
528    }
529}
530
531/// Detect a leading `[ ]`/`[X]`/`[-]` checkbox on a list item.
532fn split_checkbox(text: &str) -> (Option<Checkbox>, &str) {
533    let bytes = text.as_bytes();
534    if bytes.len() >= 3 && bytes[0] == b'[' && bytes[2] == b']' {
535        let cb = match bytes[1] {
536            b' ' => Some(Checkbox::Off),
537            b'X' | b'x' => Some(Checkbox::On),
538            b'-' => Some(Checkbox::Trans),
539            _ => None,
540        };
541        if let Some(cb) = cb {
542            return (Some(cb), text[3..].trim_start());
543        }
544    }
545    (None, text)
546}
547
548// ---------------------------------------------------------------------------
549// Line predicates / small parsers
550// ---------------------------------------------------------------------------
551
552fn block_begin(line: &str) -> Option<(String, String)> {
553    let t = line.trim_start();
554    let upper = t.to_ascii_uppercase();
555    let rest_upper = upper.strip_prefix("#+BEGIN_")?;
556    let kind_len = rest_upper
557        .find(char::is_whitespace)
558        .unwrap_or(rest_upper.len());
559    // Index back into the original-case string past "#+BEGIN_".
560    let base = t.len() - rest_upper.len();
561    let kind = t[base..base + kind_len].to_string();
562    let after = t[base + kind_len..].trim().to_string();
563    Some((kind, after))
564}
565
566fn is_block_end(line: &str) -> bool {
567    line.trim_start().to_ascii_uppercase().starts_with("#+END_")
568}
569
570fn parse_src_header(after: &str) -> (Option<String>, BlockParams) {
571    let mut parts = after.splitn(2, char::is_whitespace);
572    let lang = parts.next().filter(|s| !s.is_empty()).map(|s| s.to_string());
573    let params = BlockParams {
574        raw: parts.next().unwrap_or("").trim().to_string(),
575    };
576    (lang, params)
577}
578
579/// `#+KEY: value`, excluding `#+BEGIN_`/`#+END_` block delimiters.
580fn keyword_kv(line: &str) -> Option<(String, String)> {
581    let t = line.trim_start();
582    let rest = t.strip_prefix("#+")?;
583    if rest.to_ascii_uppercase().starts_with("BEGIN_")
584        || rest.to_ascii_uppercase().starts_with("END_")
585    {
586        return None;
587    }
588    let colon = rest.find(':')?;
589    let key = rest[..colon].trim().to_string();
590    if key.is_empty() {
591        return None;
592    }
593    let value = rest[colon + 1..].trim().to_string();
594    Some((key, value))
595}
596
597fn is_rule(line: &str) -> bool {
598    let t = line.trim();
599    t.len() >= 5 && t.chars().all(|c| c == '-')
600}
601
602fn is_drawer_begin(t: &str) -> bool {
603    if !t.starts_with(':') || !t.ends_with(':') || t.len() < 3 {
604        return false;
605    }
606    let inner = &t[1..t.len() - 1];
607    !inner.is_empty()
608        && inner
609            .chars()
610            .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
611}
612
613/// If `t` (already left-trimmed) begins a list item, return its bullet.
614fn is_list_item(t: &str) -> Option<Bullet> {
615    let bytes = t.as_bytes();
616    if bytes.is_empty() {
617        return None;
618    }
619    if (bytes[0] == b'-' || bytes[0] == b'+')
620        && (bytes.len() == 1 || bytes[1] == b' ')
621    {
622        return Some(if bytes[0] == b'-' {
623            Bullet::Dash
624        } else {
625            Bullet::Plus
626        });
627    }
628    let digits: String = t.chars().take_while(|c| c.is_ascii_digit()).collect();
629    if !digits.is_empty() {
630        let after = &t[digits.len()..];
631        if (after.starts_with('.') || after.starts_with(')'))
632            && (after.len() == 1 || after.as_bytes()[1] == b' ')
633        {
634            if let Ok(n) = digits.parse::<u32>() {
635                return Some(Bullet::Ordered(n));
636            }
637        }
638    }
639    None
640}
641
642// ---------------------------------------------------------------------------
643// Inline tokenizer (spec §3.1, R3)
644// ---------------------------------------------------------------------------
645
646fn parse_inline_run(chars: &[char]) -> Vec<Object> {
647    let mut out = Vec::new();
648    let mut buf = String::new();
649    let mut i = 0;
650    let n = chars.len();
651    while i < n {
652        let c = chars[i];
653        if c == '[' && starts_with_at(chars, i, "[fn:") {
654            if let Some((obj, next)) = try_footnote_ref(chars, i) {
655                flush(&mut buf, &mut out);
656                out.push(obj);
657                i = next;
658                continue;
659            }
660        }
661        if c == '[' && i + 1 < n && chars[i + 1] == '[' {
662            if let Some((obj, next)) = try_link(chars, i) {
663                flush(&mut buf, &mut out);
664                out.push(obj);
665                i = next;
666                continue;
667            }
668        }
669        if is_scheme_start(chars, i) && boundary_before(chars, i) {
670            if let Some((obj, next)) = try_bare_url(chars, i) {
671                flush(&mut buf, &mut out);
672                out.push(obj);
673                i = next;
674                continue;
675            }
676        }
677        if is_marker(c) {
678            if let Some((obj, next)) = try_emphasis(chars, i) {
679                flush(&mut buf, &mut out);
680                out.push(obj);
681                i = next;
682                continue;
683            }
684        }
685        buf.push(c);
686        i += 1;
687    }
688    flush(&mut buf, &mut out);
689    out
690}
691
692fn flush(buf: &mut String, out: &mut Vec<Object>) {
693    if !buf.is_empty() {
694        out.push(Object::Text(std::mem::take(buf)));
695    }
696}
697
698fn starts_with_at(chars: &[char], i: usize, needle: &str) -> bool {
699    let n: Vec<char> = needle.chars().collect();
700    i + n.len() <= chars.len() && chars[i..i + n.len()] == n[..]
701}
702
703/// A footnote reference: `[fn:LABEL]` (referenced) or `[fn:LABEL:text]` (inline
704/// definition). Anonymous inline footnotes `[fn::text]` carry an empty label.
705fn try_footnote_ref(chars: &[char], i: usize) -> Option<(Object, usize)> {
706    let n = chars.len();
707    let close = (i + 1..n).find(|&k| chars[k] == ']')?;
708    let inner: String = chars[i + 1..close].iter().collect();
709    let rest = inner.strip_prefix("fn:")?;
710    let (label, inline_objs) = match rest.split_once(':') {
711        Some((l, txt)) => {
712            let txt_chars: Vec<char> = txt.chars().collect();
713            (l.to_string(), Some(parse_inline_run(&txt_chars)))
714        }
715        None => (rest.to_string(), None),
716    };
717    if label.is_empty() && inline_objs.is_none() {
718        return None;
719    }
720    Some((
721        Object::FootnoteRef {
722            label,
723            inline: inline_objs,
724        },
725        close + 1,
726    ))
727}
728
729/// `[[target]]` or `[[target][description]]`.
730fn try_link(chars: &[char], i: usize) -> Option<(Object, usize)> {
731    let n = chars.len();
732    let mut j = i + 2;
733    while j + 1 < n {
734        if chars[j] == ']' && chars[j + 1] == ']' {
735            let inner = &chars[i + 2..j];
736            let (target_str, desc) = split_link_inner(inner);
737            let target = parse_target(&target_str);
738            let description = desc.map(|d| parse_inline_run(&d));
739            return Some((Object::Link(Link { target, description }), j + 2));
740        }
741        j += 1;
742    }
743    None
744}
745
746/// Split `target][desc` into its two halves at the first `][`.
747fn split_link_inner(inner: &[char]) -> (String, Option<Vec<char>>) {
748    for k in 0..inner.len().saturating_sub(1) {
749        if inner[k] == ']' && inner[k + 1] == '[' {
750            let target: String = inner[..k].iter().collect();
751            let desc: Vec<char> = inner[k + 2..].to_vec();
752            return (target, Some(desc));
753        }
754    }
755    (inner.iter().collect(), None)
756}
757
758fn parse_target(s: &str) -> LinkTarget {
759    if let Some(r) = s.strip_prefix('#') {
760        LinkTarget::CustomId(r.to_string())
761    } else if let Some(r) = s.strip_prefix("id:") {
762        LinkTarget::Id(r.to_string())
763    } else if let Some(r) = s.strip_prefix('*') {
764        LinkTarget::Heading(r.to_string())
765    } else if let Some(r) = s.strip_prefix("file:") {
766        LinkTarget::File {
767            path: r.into(),
768            search: None,
769        }
770    } else if is_external_scheme(s) {
771        LinkTarget::External(s.to_string())
772    } else {
773        LinkTarget::File {
774            path: s.into(),
775            search: None,
776        }
777    }
778}
779
780fn is_external_scheme(s: &str) -> bool {
781    let s = s.to_ascii_lowercase();
782    ["http://", "https://", "mailto:", "ftp://", "news:", "tel:"]
783        .iter()
784        .any(|p| s.starts_with(p))
785}
786
787fn is_scheme_start(chars: &[char], i: usize) -> bool {
788    let tail: String = chars[i..].iter().take(8).collect();
789    let tail = tail.to_ascii_lowercase();
790    tail.starts_with("http://") || tail.starts_with("https://") || tail.starts_with("mailto:")
791}
792
793/// A bare URL in running text, e.g. `https://example.com`.
794fn try_bare_url(chars: &[char], i: usize) -> Option<(Object, usize)> {
795    let n = chars.len();
796    let mut j = i;
797    while j < n {
798        let c = chars[j];
799        if c.is_whitespace() || matches!(c, '<' | '>' | '[' | ']' | '"' | '{' | '}') {
800            break;
801        }
802        j += 1;
803    }
804    // Trim trailing sentence punctuation that is unlikely to be part of the URL.
805    while j > i && matches!(chars[j - 1], '.' | ',' | ';' | ':' | '!' | '?' | ')') {
806        j -= 1;
807    }
808    if j <= i {
809        return None;
810    }
811    let url: String = chars[i..j].iter().collect();
812    Some((
813        Object::Link(Link {
814            target: LinkTarget::External(url),
815            description: None,
816        }),
817        j,
818    ))
819}
820
821fn is_marker(c: char) -> bool {
822    matches!(c, '*' | '/' | '_' | '+' | '=' | '~')
823}
824
825fn pre_ok(prev: Option<char>) -> bool {
826    match prev {
827        None => true,
828        Some(c) => c.is_whitespace() || matches!(c, '-' | '(' | '{' | '\'' | '"'),
829    }
830}
831
832fn post_ok(next: Option<char>) -> bool {
833    match next {
834        None => true,
835        Some(c) => {
836            c.is_whitespace() || matches!(c, '-' | '.' | ',' | ';' | ':' | '!' | '?' | ')' | '}' | '[' | '"' | '\'')
837        }
838    }
839}
840
841/// Org emphasis with pre/post-char boundary rules. `=`/`~` carry literal content.
842fn try_emphasis(chars: &[char], i: usize) -> Option<(Object, usize)> {
843    let n = chars.len();
844    let m = chars[i];
845    let prev = if i == 0 { None } else { Some(chars[i - 1]) };
846    if !pre_ok(prev) {
847        return None;
848    }
849    if i + 1 >= n {
850        return None;
851    }
852    let after = chars[i + 1];
853    if after.is_whitespace() || after == m {
854        return None;
855    }
856    let mut j = i + 1;
857    while j < n {
858        if chars[j] == m && j > i + 1 {
859            let before = chars[j - 1];
860            let next = chars.get(j + 1).copied();
861            if !before.is_whitespace() && post_ok(next) {
862                let inner = &chars[i + 1..j];
863                let obj = match m {
864                    '=' => Object::Verbatim(inner.iter().collect()),
865                    '~' => Object::Code(inner.iter().collect()),
866                    '*' => Object::Bold(parse_inline_run(inner)),
867                    '/' => Object::Italic(parse_inline_run(inner)),
868                    '_' => Object::Underline(parse_inline_run(inner)),
869                    '+' => Object::StrikeThrough(parse_inline_run(inner)),
870                    _ => unreachable!(),
871                };
872                return Some((obj, j + 1));
873            }
874        }
875        j += 1;
876    }
877    None
878}
879
880fn boundary_before(chars: &[char], i: usize) -> bool {
881    if i == 0 {
882        return true;
883    }
884    let c = chars[i - 1];
885    c.is_whitespace() || matches!(c, '(' | '[' | '{' | '<' | '"' | '\'')
886}