krz/orgo

Lightning fast org-mode static site generator. fast go org-mode static-site-generator

src/parser.rs

1538 lines · 53472 bytes

   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//! Scope is `docs/guide/05-org-support.org` §Supported: headings with nesting, TODO
  13//! keywords, priorities, tags and property drawers; paragraphs; plain lists (unordered,
  14//! ordered, description) with checkboxes and nesting; tables; footnotes; `#+` keywords;
  15//! source, example, quote, center and export blocks; inline markup, links, timestamps;
  16//! images with `#+CAPTION`/`#+ATTR_HTML`.
  17//!
  18//! A block name with no dedicated handling is a special block: a div carrying the name,
  19//! holding parsed org, which is what org's exporter emits for it.
  20//!
  21//! Out-of-scope constructs are parsed-and-ignored, never fatal: babel `:results` is an
  22//! inert keyword, generic drawers are captured and dropped at render, and LaTeX, macros
  23//! and radio targets survive as literal text. `#+TBLFM:` is inert for the same reason
  24//! org's exporter leaves it alone: it does not recalculate on export either.
  25
  26use camino::Utf8Path;
  27use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
  28
  29use crate::model::{
  30    BlockParams, Bullet, Checkbox, ContentHash, Document, Element, Heading, Keywords, Link,
  31    Diagnostic, LinkTarget, List, ListItem, ListKind, Object, Properties, Section, Table, TableRow,
  32    Timestamp, TodoKeyword,
  33};
  34
  35#[derive(Debug, thiserror::Error)]
  36pub enum ParseError {
  37    #[error("parse error at line {line}: {message}")]
  38    At { line: usize, message: String },
  39}
  40
  41/// Classified lines produced by the first pass (spec §3.1).
  42#[derive(Debug, Clone, PartialEq, Eq)]
  43pub enum Line {
  44    Heading,
  45    BlockBegin { kind: String },
  46    BlockEnd,
  47    ListItem,
  48    TableRow,
  49    Keyword,
  50    DrawerBegin,
  51    DrawerEnd,
  52    Rule,
  53    Blank,
  54    Text,
  55}
  56
  57/// First pass: classify each raw line. Context-free per line.
  58pub fn line_lexer(source: &str) -> Vec<Line> {
  59    source.lines().map(classify_line).collect()
  60}
  61
  62fn classify_line(line: &str) -> Line {
  63    if line.trim().is_empty() {
  64        return Line::Blank;
  65    }
  66    if heading_level(line).is_some() {
  67        return Line::Heading;
  68    }
  69    let t = line.trim_start();
  70    let upper = t.to_ascii_uppercase();
  71    if let Some(rest) = upper.strip_prefix("#+BEGIN_") {
  72        let kind = rest.split_whitespace().next().unwrap_or("").to_string();
  73        return Line::BlockBegin { kind };
  74    }
  75    if upper.starts_with("#+END_") {
  76        return Line::BlockEnd;
  77    }
  78    if keyword_kv(line).is_some() {
  79        return Line::Keyword;
  80    }
  81    if is_rule(line) {
  82        return Line::Rule;
  83    }
  84    if t.eq_ignore_ascii_case(":END:") {
  85        return Line::DrawerEnd;
  86    }
  87    if is_drawer_begin(t) {
  88        return Line::DrawerBegin;
  89    }
  90    if is_list_item(t).is_some() {
  91        return Line::ListItem;
  92    }
  93    if t.starts_with('|') {
  94        return Line::TableRow;
  95    }
  96    Line::Text
  97}
  98
  99/// blake3 of raw source bytes — the content hash that drives re-parse decisions (spec §4.1).
 100pub fn content_hash(bytes: &[u8]) -> ContentHash {
 101    ContentHash(*blake3::hash(bytes).as_bytes())
 102}
 103
 104/// Parse one source file into a [`Document`]. Pure over `(path, source)`.
 105pub fn parse(path: &Utf8Path, source: &str) -> Result<Document, ParseError> {
 106    let content_hash = content_hash(source.as_bytes());
 107    let lines: Vec<&str> = source.lines().collect();
 108    let classes = line_lexer(source);
 109
 110    let mut diagnostics: Vec<Diagnostic> = Vec::new();
 111    let mut keywords = Keywords::default();
 112    let mut root = Section {
 113        heading: None,
 114        content: Vec::new(),
 115        children: Vec::new(),
 116    };
 117
 118    let heading_idxs: Vec<usize> = classes
 119        .iter()
 120        .enumerate()
 121        .filter(|(_, c)| **c == Line::Heading)
 122        .map(|(i, _)| i)
 123        .collect();
 124    let first = heading_idxs.first().copied().unwrap_or(lines.len());
 125
 126    // Preamble: document-level keywords are *copied* into `keywords`, which is the
 127    // metadata map. They are not removed from the body — collecting is not deleting.
 128    // Dropping the lines would merge the paragraphs either side of a keyword and would
 129    // strand affiliated keywords (`#+CAPTION:`) away from the element they belong to;
 130    // left in place, `parse_elements` handles both. Affiliated keywords are not document
 131    // metadata, so they are not copied.
 132    {
 133        for (l, c) in lines[..first].iter().zip(&classes[..first]) {
 134            if *c == Line::Keyword {
 135                if let Some((k, v)) = keyword_kv(l) {
 136                    if !is_affiliated(&k) {
 137                        keywords.entries.push((k, v));
 138                    }
 139                }
 140            }
 141        }
 142        root.content = parse_elements(&lines[..first], 0, &mut diagnostics);
 143    }
 144
 145    // Each heading segment runs from its own line up to (but excluding) the next heading.
 146    let mut flat: Vec<(u8, Section)> = Vec::new();
 147    for (k, &h_idx) in heading_idxs.iter().enumerate() {
 148        let end = heading_idxs.get(k + 1).copied().unwrap_or(lines.len());
 149        let heading = parse_heading(lines[h_idx]);
 150        let level = heading.level;
 151        let (heading, content) =
 152            parse_section_body(heading, &lines[h_idx + 1..end], h_idx + 1, &mut diagnostics);
 153        flat.push((
 154            level,
 155            Section {
 156                heading: Some(heading),
 157                content,
 158                children: Vec::new(),
 159            },
 160        ));
 161    }
 162
 163    let mut pos = 0;
 164    root.children = build_children(&mut flat, &mut pos, 0);
 165
 166    diagnostics.sort_by_key(|d| d.line);
 167    Ok(Document {
 168        source_path: path.to_owned(),
 169        content_hash,
 170        keywords,
 171        root,
 172        diagnostics,
 173    })
 174}
 175
 176/// Fold the flat `(level, section)` list into org's nested hierarchy by level.
 177fn build_children(flat: &mut [(u8, Section)], pos: &mut usize, parent_level: u8) -> Vec<Section> {
 178    let mut children = Vec::new();
 179    while *pos < flat.len() {
 180        let level = flat[*pos].0;
 181        if level <= parent_level {
 182            break;
 183        }
 184        let mut section = std::mem::replace(&mut flat[*pos].1, empty_section());
 185        *pos += 1;
 186        section.children = build_children(flat, pos, level);
 187        children.push(section);
 188    }
 189    children
 190}
 191
 192fn empty_section() -> Section {
 193    Section {
 194        heading: None,
 195        content: Vec::new(),
 196        children: Vec::new(),
 197    }
 198}
 199
 200/// Second-tier: scan an element's text into inline objects, applying org's
 201/// pre/post-char emphasis rules (spec §3.1, R3 — the highest-divergence area).
 202pub fn inline(text: &str) -> Vec<Object> {
 203    let chars: Vec<char> = text.chars().collect();
 204    parse_inline_run(&chars)
 205}
 206
 207// ---------------------------------------------------------------------------
 208// Headings
 209// ---------------------------------------------------------------------------
 210
 211/// `*`-prefixed heading depth, or `None` if the line is not a heading.
 212fn heading_level(line: &str) -> Option<u8> {
 213    if !line.starts_with('*') {
 214        return None;
 215    }
 216    let stars = line.chars().take_while(|c| *c == '*').count();
 217    let after = &line[stars..];
 218    if after.starts_with(' ') || after.is_empty() {
 219        Some(stars.min(u8::MAX as usize) as u8)
 220    } else {
 221        None
 222    }
 223}
 224
 225/// The default TODO keyword set, matching Emacs' out-of-the-box `org-todo-keywords`
 226/// (`("TODO" "DONE")`) so our output can be diffed against an `emacs --batch` oracle.
 227/// Per-file `#+TODO:` sequences are out of scope; the set is a documented [`BuildConfig`]
 228/// slot for when it becomes configurable.
 229///
 230/// [`BuildConfig`]: crate::incremental::BuildConfig
 231const TODO_KEYWORDS: &[(&str, bool)] = &[("TODO", false), ("DONE", true)];
 232
 233fn parse_heading(line: &str) -> Heading {
 234    let level = heading_level(line).unwrap_or(1);
 235    let rest = line[level as usize..].trim();
 236    let (title_str, tags) = split_tags(rest);
 237    let (todo, after_todo) = split_todo(title_str.trim());
 238    let (priority, title_str) = split_priority(after_todo);
 239    Heading {
 240        level,
 241        todo,
 242        priority,
 243        title: inline(title_str.trim()),
 244        tags,
 245        properties: Properties::default(),
 246        id: None,
 247        custom_id: None,
 248    }
 249}
 250
 251/// A leading TODO keyword: a bare word from the keyword set, followed by whitespace or
 252/// end of the heading. `*  TODOs are great` is NOT a keyword (no word boundary).
 253fn split_todo(title: &str) -> (Option<TodoKeyword>, &str) {
 254    let word_end = title.find(char::is_whitespace).unwrap_or(title.len());
 255    let word = &title[..word_end];
 256    for (name, done) in TODO_KEYWORDS {
 257        if word == *name {
 258            return (
 259                Some(TodoKeyword {
 260                    name: (*name).to_string(),
 261                    done: *done,
 262                }),
 263                title[word_end..].trim_start(),
 264            );
 265        }
 266    }
 267    (None, title)
 268}
 269
 270/// A priority cookie `[#A]` immediately after the TODO keyword.
 271fn split_priority(title: &str) -> (Option<char>, &str) {
 272    let Some(rest) = title.strip_prefix("[#") else {
 273        return (None, title);
 274    };
 275    let mut chars = rest.chars();
 276    let Some(c) = chars.next().filter(|c| c.is_ascii_alphanumeric()) else {
 277        return (None, title);
 278    };
 279    match chars.next() {
 280        Some(']') => (
 281            Some(c.to_ascii_uppercase()),
 282            rest[c.len_utf8() + 1..].trim_start(),
 283        ),
 284        _ => (None, title),
 285    }
 286}
 287
 288/// Split a trailing `:tag1:tag2:` cluster off the heading text.
 289fn split_tags(rest: &str) -> (&str, Vec<String>) {
 290    let trimmed = rest.trim_end();
 291    if !trimmed.ends_with(':') {
 292        return (rest, Vec::new());
 293    }
 294    let start = match trimmed.rfind(char::is_whitespace) {
 295        Some(i) => i + 1,
 296        None => 0,
 297    };
 298    let candidate = &trimmed[start..];
 299    if is_tag_cluster(candidate) {
 300        let tags = candidate
 301            .split(':')
 302            .filter(|s| !s.is_empty())
 303            .map(|s| s.to_string())
 304            .collect();
 305        (&trimmed[..start], tags)
 306    } else {
 307        (rest, Vec::new())
 308    }
 309}
 310
 311/// A `:a:b:c:` cluster: colon-delimited, non-empty tag names, colon-bounded.
 312fn is_tag_cluster(s: &str) -> bool {
 313    if !s.starts_with(':') || !s.ends_with(':') || s.len() < 3 {
 314        return false;
 315    }
 316    let inner = &s[1..s.len() - 1];
 317    !inner.is_empty()
 318        && inner.split(':').all(|part| {
 319            !part.is_empty()
 320                && part
 321                    .chars()
 322                    .all(|c| c.is_alphanumeric() || matches!(c, '_' | '@' | '#' | '%'))
 323        })
 324}
 325
 326// ---------------------------------------------------------------------------
 327// Section body: property drawer + block content
 328// ---------------------------------------------------------------------------
 329
 330fn parse_section_body(
 331    mut heading: Heading,
 332    body: &[&str],
 333    base: usize,
 334    diags: &mut Vec<Diagnostic>,
 335) -> (Heading, Vec<Element>) {
 336    let mut idx = 0;
 337    while idx < body.len() && body[idx].trim().is_empty() {
 338        idx += 1;
 339    }
 340    if idx < body.len() && body[idx].trim().eq_ignore_ascii_case(":PROPERTIES:") {
 341        idx = parse_property_drawer(&mut heading, body, idx, base, diags);
 342    }
 343    let content = parse_elements(&body[idx..], base + idx, diags);
 344    (heading, content)
 345}
 346
 347/// Read the `:PROPERTIES:` drawer opening at `body[open]` into `heading`, returning the
 348/// index of the first line past it.
 349fn parse_property_drawer(
 350    heading: &mut Heading,
 351    body: &[&str],
 352    open: usize,
 353    base: usize,
 354    diags: &mut Vec<Diagnostic>,
 355) -> usize {
 356    let mut idx = open + 1;
 357    while idx < body.len() {
 358        let t = body[idx].trim();
 359        if t.eq_ignore_ascii_case(":END:") {
 360            return idx + 1;
 361        }
 362        if let Some((k, v)) = parse_property(t) {
 363            if k.eq_ignore_ascii_case("CUSTOM_ID") {
 364                heading.custom_id = Some(v.clone());
 365            } else if k.eq_ignore_ascii_case("ID") {
 366                heading.id = Some(v.clone());
 367            }
 368            heading.properties.entries.push((k, v));
 369        }
 370        idx += 1;
 371    }
 372    diags.push(Diagnostic {
 373        line: base + open + 1,
 374        message: "unterminated :PROPERTIES: drawer (no :END:); the rest of the \
 375                  section was read as properties"
 376            .to_string(),
 377    });
 378    idx
 379}
 380
 381/// `:KEY: value` inside a drawer.
 382fn parse_property(line: &str) -> Option<(String, String)> {
 383    let line = line.trim();
 384    let line = line.strip_prefix(':')?;
 385    let end = line.find(':')?;
 386    let key = line[..end].trim().to_string();
 387    if key.is_empty() {
 388        return None;
 389    }
 390    let value = line[end + 1..].trim().to_string();
 391    Some((key, value))
 392}
 393
 394// ---------------------------------------------------------------------------
 395// Block-level element builder
 396// ---------------------------------------------------------------------------
 397
 398/// Build the block elements of `lines`. `base` is the absolute 0-based index of
 399/// `lines[0]` in the source file, so diagnostics can name a real line number however
 400/// deeply nested the construct is.
 401fn parse_elements(lines: &[&str], base: usize, diags: &mut Vec<Diagnostic>) -> Vec<Element> {
 402    let mut out = Vec::new();
 403    // Affiliated keywords (`#+CAPTION:` and friends) belong to the element that follows
 404    // them, so they are held aside until that element is built.
 405    let mut affiliated: Vec<(String, String)> = Vec::new();
 406    let mut drop_next = false;
 407    let mut i = 0;
 408    while i < lines.len() {
 409        let line = lines[i];
 410        if line.trim().is_empty() {
 411            // A blank line ends the association: an affiliated keyword belongs to the
 412            // element *immediately* below it. Someone who writes `#+CAPTION:` under their
 413            // image and then leaves a blank line has captioned nothing, and org agrees —
 414            // silently attaching it to whatever comes next would caption the wrong thing.
 415            affiliated.clear();
 416            i += 1;
 417            continue;
 418        }
 419        if let Some((key, value)) = keyword_kv(line) {
 420            match keyword_role(&key, &value, base + i + 1, diags) {
 421                KeywordRole::DropNext => drop_next = true,
 422                KeywordRole::Affiliated => affiliated.push((key, value)),
 423                KeywordRole::Standalone => out.push(Element::Keyword { key, value }),
 424            }
 425            i += 1;
 426            continue;
 427        }
 428        let (element, next) = parse_one_element(lines, i, base, diags);
 429        i = next;
 430        if std::mem::take(&mut drop_next) {
 431            affiliated.clear();
 432            continue;
 433        }
 434        if let Some(element) = element {
 435            out.push(attach_affiliated(element, std::mem::take(&mut affiliated)));
 436        }
 437    }
 438    out
 439}
 440
 441/// What a `#+KEY: value` line contributes to the elements around it.
 442enum KeywordRole {
 443    /// Drop the element that follows.
 444    DropNext,
 445    /// Attach to the element that follows.
 446    Affiliated,
 447    /// An element of its own.
 448    Standalone,
 449}
 450
 451/// Classify a keyword line, reporting keywords orgo does not act on. `line` is the
 452/// 1-based source line.
 453fn keyword_role(key: &str, value: &str, line: usize, diags: &mut Vec<Diagnostic>) -> KeywordRole {
 454    if key.eq_ignore_ascii_case("INCLUDE") {
 455        // Never expanded (§"Not supported"). Expanding it means resolving paths,
 456        // recursion and `:lines`/`:only-contents`; dropping it silently means a
 457        // page missing content nobody was told about. Saying so is the honest
 458        // middle, and `--strict` turns it into a failure.
 459        diags.push(Diagnostic {
 460            line,
 461            message: format!(
 462                "`#+INCLUDE: {}` is not expanded; that content will be missing \
 463                 from the page",
 464                value.trim()
 465            ),
 466        });
 467    }
 468    if key.eq_ignore_ascii_case("RESULTS") {
 469        // Babel is never executed (§"Not supported"), so a checked-in `#+RESULTS:`
 470        // block is output from someone else's Emacs session at some other time.
 471        // Emitting it would put unverifiable content on the page dressed as
 472        // real content, so the block it labels is dropped.
 473        KeywordRole::DropNext
 474    } else if is_affiliated(key) {
 475        KeywordRole::Affiliated
 476    } else {
 477        KeywordRole::Standalone
 478    }
 479}
 480
 481/// Build the single element starting at `lines[start]`, returning it with the index of
 482/// the first line past it. `None` means the lines were consumed without producing an
 483/// element. `start` is guaranteed non-blank and not an affiliated keyword.
 484fn parse_one_element(
 485    lines: &[&str],
 486    start: usize,
 487    base: usize,
 488    diags: &mut Vec<Diagnostic>,
 489) -> (Option<Element>, usize) {
 490    let line = lines[start];
 491    if let Some(text) = comment_text(line) {
 492        return (Some(Element::Comment(text)), start + 1);
 493    }
 494    if let Some(header) = block_begin(line) {
 495        let (el, next) = parse_block(lines, start, &header, base, diags);
 496        return (Some(el), next);
 497    }
 498    if let Some(name) = drawer_begin_name(line) {
 499        let (el, next) = parse_drawer(lines, start, name, base, diags);
 500        return (Some(el), next);
 501    }
 502    if is_rule(line) {
 503        return (Some(Element::HorizontalRule), start + 1);
 504    }
 505    if line.trim_start().starts_with('|') {
 506        let (table, next) = parse_table(lines, start);
 507        return (Some(Element::Table(table)), next);
 508    }
 509    if let Some((label, first_rest)) = footnote_def_label(line) {
 510        let (def, next) = parse_footnote_def(lines, start, label, first_rest);
 511        return (Some(def), next);
 512    }
 513    if is_list_item(line.trim_start()).is_some() {
 514        let (list, next) = parse_list(lines, start, base, diags);
 515        return (Some(Element::List(list)), next);
 516    }
 517    // Paragraph: gather consecutive soft-wrapped text lines.
 518    let mut para = Vec::new();
 519    let mut i = start;
 520    while i < lines.len() {
 521        let l = lines[i];
 522        if l.trim().is_empty() || is_structural(l) {
 523            break;
 524        }
 525        para.push(l.trim());
 526        i += 1;
 527    }
 528    if para.is_empty() {
 529        // `is_structural` said this line begins a construct that no branch above claimed.
 530        // In practice that is a stray `#+END_`: a block terminator with nothing open.
 531        // Skip it rather than looping forever, but say so — it usually means a `#+BEGIN_`
 532        // above it is misspelled, and silence would leave the author hunting.
 533        if is_block_end(line) {
 534            diags.push(Diagnostic {
 535                line: base + start + 1,
 536                message: format!(
 537                    "stray `{}` with no matching `#+BEGIN_`",
 538                    line.split_whitespace().next().unwrap_or("#+END_")
 539                ),
 540            });
 541        }
 542        return (None, start + 1);
 543    }
 544    (Some(Element::Paragraph(inline(&para.join(" ")))), i)
 545}
 546
 547/// Tests for each line that starts a non-paragraph construct.
 548const STRUCTURAL: [fn(&str) -> bool; 10] = [
 549    |l| block_begin(l).is_some(),
 550    is_block_end,
 551    is_rule,
 552    |l| keyword_kv(l).is_some(),
 553    |l| comment_text(l).is_some(),
 554    |l| drawer_begin_name(l).is_some(),
 555    |l| is_list_item(l.trim_start()).is_some(),
 556    |l| l.trim_start().starts_with('|'),
 557    |l| footnote_def_label(l).is_some(),
 558    |l| heading_level(l).is_some(),
 559];
 560
 561/// Is this line the start of a non-paragraph construct?
 562fn is_structural(line: &str) -> bool {
 563    STRUCTURAL.iter().any(|test| test(line))
 564}
 565
 566// ---------------------------------------------------------------------------
 567// Blocks, drawers, comments, affiliated keywords
 568// ---------------------------------------------------------------------------
 569
 570/// Consume `#+BEGIN_<KIND> … #+END_<KIND>`. Matching is on the *specific* kind so a
 571/// source block can sit inside a quote block; an unterminated block runs to end of
 572/// input rather than failing.
 573fn parse_block(
 574    lines: &[&str],
 575    start: usize,
 576    (kind, after): &(String, String),
 577    base: usize,
 578    diags: &mut Vec<Diagnostic>,
 579) -> (Element, usize) {
 580    let mut inner: Vec<String> = Vec::new();
 581    let mut j = start + 1;
 582    while j < lines.len() && !is_block_end_of(lines[j], kind) {
 583        inner.push(unescape_block_line(lines[j]));
 584        j += 1;
 585    }
 586    let inner: Vec<&str> = inner.iter().map(String::as_str).collect();
 587    if j >= lines.len() {
 588        // Everything to the end of input was swallowed by the block. This is the single
 589        // most destructive malformation in org: one missing line silently deletes the
 590        // rest of the document from the output.
 591        diags.push(Diagnostic {
 592            line: base + start + 1,
 593            message: format!(
 594                "unterminated `#+BEGIN_{}` block (no `#+END_{}`); \
 595                 everything to the end of the file was read as block content",
 596                kind.to_ascii_uppercase(),
 597                kind.to_ascii_uppercase()
 598            ),
 599        });
 600    }
 601    let next = if j < lines.len() { j + 1 } else { j };
 602    let element = match kind.to_ascii_uppercase().as_str() {
 603        "SRC" => {
 604            let (lang, params) = parse_src_header(after);
 605            Element::SrcBlock {
 606                lang,
 607                params,
 608                code: inner.join("\n"),
 609            }
 610        }
 611        "EXAMPLE" => Element::ExampleBlock(inner.join("\n")),
 612        "QUOTE" => Element::QuoteBlock(parse_elements(&inner, base + start + 1, diags)),
 613        "CENTER" => Element::CenterBlock(parse_elements(&inner, base + start + 1, diags)),
 614        "EXPORT" => Element::ExportBlock {
 615            backend: after.split_whitespace().next().unwrap_or("").to_string(),
 616            raw: inner.join("\n"),
 617        },
 618        // Verse keeps its line breaks; that is the whole point of it.
 619        "VERSE" => Element::VerseBlock(inner.iter().map(|l| l.to_string()).collect()),
 620        // A comment block is not published, in org or here.
 621        "COMMENT" => Element::Comment(inner.join("\n")),
 622        // Any other name is a special block: a div with that class, holding org. Emacs
 623        // exports unknown block types this way, which is what makes `#+BEGIN_NOTE` a
 624        // usable convention without the exporter knowing the word "note".
 625        other => Element::SpecialBlock {
 626            name: other.to_ascii_lowercase(),
 627            content: parse_elements(&inner, base + start + 1, diags),
 628        },
 629    };
 630    (element, next)
 631}
 632
 633/// Undo org's comma escape on one line of block content.
 634///
 635/// A line inside a block that would otherwise look like document structure is written
 636/// with a leading comma — `,* heading`, `,#+KEYWORD:` — and the exporter removes exactly
 637/// one comma. Without this, documentation *about* org shows the escape characters its
 638/// author had to type, which is precisely the audience most likely to notice.
 639fn unescape_block_line(line: &str) -> String {
 640    let trimmed = line.trim_start();
 641    let Some(rest) = trimmed.strip_prefix(',') else {
 642        return line.to_string();
 643    };
 644    if !(rest.starts_with('*') || rest.starts_with("#+") || rest.starts_with(',')) {
 645        return line.to_string();
 646    }
 647    let indent = &line[..line.len() - trimmed.len()];
 648    format!("{indent}{rest}")
 649}
 650
 651/// `:NAME:` … `:END:` at block level. A PROPERTIES drawer directly under a heading is
 652/// consumed by [`parse_section_body`]; anything reaching here is a generic drawer,
 653/// which the renderer drops (§"Not supported").
 654fn parse_drawer(
 655    lines: &[&str],
 656    start: usize,
 657    name: String,
 658    base: usize,
 659    diags: &mut Vec<Diagnostic>,
 660) -> (Element, usize) {
 661    let mut inner: Vec<&str> = Vec::new();
 662    let mut j = start + 1;
 663    while j < lines.len() && !lines[j].trim().eq_ignore_ascii_case(":END:") {
 664        inner.push(lines[j]);
 665        j += 1;
 666    }
 667    if j >= lines.len() {
 668        // Drawers render to nothing, so an unterminated one deletes the rest of the file
 669        // from the output just as thoroughly as an unterminated block — and more quietly.
 670        diags.push(Diagnostic {
 671            line: base + start + 1,
 672            message: format!(
 673                "unterminated `:{name}:` drawer (no `:END:`); everything to the end of \
 674                 the file was read as drawer content and will not be rendered"
 675            ),
 676        });
 677    }
 678    let next = if j < lines.len() { j + 1 } else { j };
 679    (
 680        Element::Drawer {
 681            name,
 682            content: parse_elements(&inner, base + start + 1, diags),
 683        },
 684        next,
 685    )
 686}
 687
 688/// The drawer name in a `:NAME:` opening line, if this line is one. `:END:` closes a
 689/// drawer rather than opening one.
 690fn drawer_begin_name(line: &str) -> Option<String> {
 691    let t = line.trim();
 692    if !is_drawer_begin(t) {
 693        return None;
 694    }
 695    let name = &t[1..t.len() - 1];
 696    if name.eq_ignore_ascii_case("END") {
 697        return None;
 698    }
 699    Some(name.to_string())
 700}
 701
 702/// A comment line: `#` followed by whitespace or nothing. `#+KEY:` is a keyword (checked
 703/// first) and `#hashtag` is ordinary text.
 704fn comment_text(line: &str) -> Option<String> {
 705    let rest = line.trim_start().strip_prefix('#')?;
 706    if rest.is_empty() {
 707        return Some(String::new());
 708    }
 709    if !rest.starts_with(char::is_whitespace) {
 710        return None;
 711    }
 712    Some(rest.trim().to_string())
 713}
 714
 715/// Keywords that attach to the element that follows them rather than standing alone.
 716fn is_affiliated(key: &str) -> bool {
 717    let k = key.to_ascii_uppercase();
 718    matches!(k.as_str(), "CAPTION" | "NAME" | "ATTR_HTML")
 719}
 720
 721/// A paragraph holding nothing but an image link becomes a block-level figure when a
 722/// `#+CAPTION:`/`#+ATTR_HTML:` precedes it, and a table takes its caption. Affiliated
 723/// keywords on anything else are parsed and dropped.
 724fn attach_affiliated(element: Element, affiliated: Vec<(String, String)>) -> Element {
 725    let value = |key: &str| {
 726        affiliated
 727            .iter()
 728            .find(|(k, _)| k.eq_ignore_ascii_case(key))
 729            .map(|(_, v)| v.clone())
 730    };
 731    let caption = value("CAPTION").unwrap_or_default();
 732    let attrs = value("ATTR_HTML").unwrap_or_default();
 733    if caption.is_empty() && attrs.is_empty() {
 734        return element;
 735    }
 736    if let Element::Table(table) = element {
 737        return Element::Table(Table {
 738            caption: inline(&caption),
 739            ..table
 740        });
 741    }
 742    let Element::Paragraph(objs) = &element else {
 743        return element;
 744    };
 745    let [Object::Link(link)] = objs.as_slice() else {
 746        return element;
 747    };
 748    if !is_image_target(&link.target) {
 749        return element;
 750    }
 751    Element::Figure {
 752        link: link.clone(),
 753        caption: inline(&caption),
 754        attrs,
 755    }
 756}
 757
 758/// Does this link point at an image file? Drives both figure promotion and inline
 759/// `<img>` rendering.
 760pub fn is_image_target(target: &LinkTarget) -> bool {
 761    let path = match target {
 762        LinkTarget::File { path, .. } => path.as_str(),
 763        LinkTarget::External(url) => url.split(['?', '#']).next().unwrap_or(url),
 764        _ => return false,
 765    };
 766    let Some(ext) = path.rsplit('.').next() else {
 767        return false;
 768    };
 769    matches!(
 770        ext.to_ascii_lowercase().as_str(),
 771        "png" | "jpg" | "jpeg" | "gif" | "svg" | "webp" | "avif"
 772    )
 773}
 774
 775// ---------------------------------------------------------------------------
 776// Tables (spec §1 IN; `#+TBLFM:` formulas are parse-and-ignored via keyword_kv)
 777// ---------------------------------------------------------------------------
 778
 779/// Consume a run of consecutive `|`-prefixed lines into a [`Table`]. Rule rows
 780/// (`|---+---|`) are preserved as [`TableRow::Rule`] so the renderer can locate the
 781/// header band.
 782fn parse_table(lines: &[&str], start: usize) -> (Table, usize) {
 783    let mut rows = Vec::new();
 784    let mut i = start;
 785    while i < lines.len() {
 786        let t = lines[i].trim_start();
 787        if !t.starts_with('|') {
 788            break;
 789        }
 790        if is_table_rule(t) {
 791            rows.push(TableRow::Rule);
 792        } else {
 793            rows.push(TableRow::Cells(parse_table_cells(t)));
 794        }
 795        i += 1;
 796    }
 797    (
 798        Table {
 799            rows,
 800            caption: Vec::new(),
 801        },
 802        i,
 803    )
 804}
 805
 806/// A rule row: only `|`, `-`, `+`, whitespace, and at least one `-`.
 807fn is_table_rule(t: &str) -> bool {
 808    t.starts_with('|')
 809        && t.contains('-')
 810        && t.chars().all(|c| matches!(c, '|' | '-' | '+' | ' '))
 811}
 812
 813fn parse_table_cells(t: &str) -> Vec<Vec<Object>> {
 814    let inner = t.trim().trim_start_matches('|').trim_end_matches('|');
 815    inner.split('|').map(|cell| inline(cell.trim())).collect()
 816}
 817
 818// ---------------------------------------------------------------------------
 819// Footnote definitions (spec §1 IN; inline refs handled in the inline tokenizer)
 820// ---------------------------------------------------------------------------
 821
 822/// A footnote *definition* line: `[fn:LABEL] text...`. Returns the label and the
 823/// remainder on the same line. `[fn:LABEL:inline]` (a colon inside the label span) is
 824/// an inline reference, not a definition, so it is rejected here.
 825fn footnote_def_label(line: &str) -> Option<(String, String)> {
 826    let t = line.trim_start();
 827    let r = t.strip_prefix("[fn:")?;
 828    let end = r.find(']')?;
 829    let label = &r[..end];
 830    if label.is_empty() || label.contains(':') {
 831        return None;
 832    }
 833    Some((label.to_string(), r[end + 1..].trim_start().to_string()))
 834}
 835
 836/// Gather a footnote definition's content: the remainder of its opening line plus
 837/// following continuation lines up to the next blank/structural/definition line.
 838fn parse_footnote_def(
 839    lines: &[&str],
 840    start: usize,
 841    label: String,
 842    first_rest: String,
 843) -> (Element, usize) {
 844    let mut parts: Vec<String> = Vec::new();
 845    if !first_rest.is_empty() {
 846        parts.push(first_rest);
 847    }
 848    let mut i = start + 1;
 849    while i < lines.len() {
 850        let l = lines[i];
 851        if l.trim().is_empty() || is_structural(l) {
 852            break;
 853        }
 854        parts.push(l.trim().to_string());
 855        i += 1;
 856    }
 857    let content = if parts.is_empty() {
 858        Vec::new()
 859    } else {
 860        vec![Element::Paragraph(inline(&parts.join(" ")))]
 861    };
 862    (Element::FootnoteDefinition { label, content }, i)
 863}
 864
 865/// Consume one plain list. Items are delimited by bullets at the list's own indent
 866/// column; everything indented further is that item's body, re-parsed as block content —
 867/// which is what makes lists nest. A single blank line does not end a list, but a blank
 868/// line followed by anything that is not a sibling bullet does.
 869fn parse_list(
 870    lines: &[&str],
 871    start: usize,
 872    base: usize,
 873    diags: &mut Vec<Diagnostic>,
 874) -> (List, usize) {
 875    let base_indent = indent_of(lines[start]);
 876    let family = bullet_family(&is_list_item(lines[start].trim_start()).expect("list item"));
 877    // A list is a description list when its FIRST item carries a `::` term separator.
 878    let kind = match (&family, split_term(item_text(lines[start].trim_start()))) {
 879        (ListKind::Ordered, _) => ListKind::Ordered,
 880        (_, Some(_)) => ListKind::Description,
 881        _ => ListKind::Unordered,
 882    };
 883
 884    let mut items = Vec::new();
 885    let mut i = start;
 886    while let Some((j, bullet)) = next_sibling(lines, i, base_indent, &family) {
 887        // Body = the text after the bullet, plus every following line indented past the
 888        // bullet column (blank lines included, so an item can hold several paragraphs).
 889        let rest = item_body(lines[j].trim_start(), &bullet);
 890        // `[@4]` comes before the checkbox: `1. [@4] [X] done`.
 891        let (counter, rest) = split_counter(rest);
 892        let (checkbox, rest) = split_checkbox(rest);
 893        let (term, rest) = match kind {
 894            ListKind::Description => match split_term(rest) {
 895                Some((term, def)) => (Some(inline(term.trim())), def),
 896                None => (None, rest),
 897            },
 898            _ => (None, rest),
 899        };
 900
 901        let body;
 902        (body, i) = item_body_lines(lines, j + 1, base_indent, rest.trim().to_string());
 903
 904        items.push(ListItem {
 905            bullet,
 906            counter,
 907            checkbox,
 908            term,
 909            // The item body starts at the bullet line, so `base + j` is exact even after
 910            // the body has been dedented into fresh strings.
 911            content: parse_elements(&dedent(&body), base + j, diags),
 912        });
 913    }
 914    (List { kind, items }, i)
 915}
 916
 917/// The next item of a list whose bullets sit at `base_indent`, searching from
 918/// `lines[from]`: its line index and bullet. Blank lines are skipped, but the list only
 919/// continues if a sibling bullet of the same family follows them.
 920fn next_sibling(
 921    lines: &[&str],
 922    from: usize,
 923    base_indent: usize,
 924    family: &ListKind,
 925) -> Option<(usize, Bullet)> {
 926    let mut j = from;
 927    while j < lines.len() && lines[j].trim().is_empty() {
 928        j += 1;
 929    }
 930    if j >= lines.len() || indent_of(lines[j]) != base_indent {
 931        return None;
 932    }
 933    let bullet = is_list_item(lines[j].trim_start())?;
 934    (bullet_family(&bullet) == *family).then_some((j, bullet))
 935}
 936
 937/// An item's body: `first` (the text after its bullet), then every line from
 938/// `lines[from]` indented past `base_indent`. Returns the body with the index of the
 939/// first line past it.
 940fn item_body_lines(
 941    lines: &[&str],
 942    from: usize,
 943    base_indent: usize,
 944    first: String,
 945) -> (Vec<String>, usize) {
 946    let mut body: Vec<String> = vec![first];
 947    let mut i = from;
 948    while i < lines.len() {
 949        if lines[i].trim().is_empty() {
 950            // Trailing blanks belong to the item only if more of it follows.
 951            let mut k = i;
 952            while k < lines.len() && lines[k].trim().is_empty() {
 953                k += 1;
 954            }
 955            if k < lines.len() && indent_of(lines[k]) > base_indent {
 956                body.resize(body.len() + (k - i), String::new());
 957                i = k;
 958                continue;
 959            }
 960            break;
 961        }
 962        if indent_of(lines[i]) <= base_indent {
 963            break;
 964        }
 965        body.push(lines[i].to_string());
 966        i += 1;
 967    }
 968    (body, i)
 969}
 970
 971/// Ordered and unordered bullets cannot share a list; description items use unordered
 972/// bullets, so they are the same family.
 973fn bullet_family(bullet: &Bullet) -> ListKind {
 974    match bullet {
 975        Bullet::Ordered(_) => ListKind::Ordered,
 976        _ => ListKind::Unordered,
 977    }
 978}
 979
 980fn indent_of(line: &str) -> usize {
 981    line.len() - line.trim_start().len()
 982}
 983
 984/// Strip the common leading indent from an item's body lines so the recursive
 985/// [`parse_elements`] call sees them at column zero. The first entry is already
 986/// dedented (it is the text that followed the bullet), so it is excluded from the
 987/// measurement.
 988fn dedent(body: &[String]) -> Vec<&str> {
 989    let common = body
 990        .iter()
 991        .skip(1)
 992        .filter(|l| !l.trim().is_empty())
 993        .map(|l| indent_of(l))
 994        .min()
 995        .unwrap_or(0);
 996    body.iter()
 997        .enumerate()
 998        .map(|(idx, l)| {
 999            if idx == 0 || l.len() < common {
1000                l.as_str()
1001            } else {
1002                &l[common..]
1003            }
1004        })
1005        .collect()
1006}
1007
1008/// The text of a list item line after its bullet, for kind detection.
1009fn item_text(t: &str) -> &str {
1010    match is_list_item(t) {
1011        Some(bullet) => item_body(t, &bullet),
1012        None => t,
1013    }
1014}
1015
1016/// Split `term :: definition`. The separator must be surrounded by whitespace (or end
1017/// the line) so `a::b` in code text is not mistaken for one.
1018fn split_term(text: &str) -> Option<(&str, &str)> {
1019    let idx = text.find(" :: ").or_else(|| {
1020        text.strip_suffix(" ::")
1021            .map(|before| before.len())
1022    })?;
1023    let term = &text[..idx];
1024    if term.trim().is_empty() {
1025        return None;
1026    }
1027    Some((term, text[idx..].trim_start_matches(" ::").trim_start()))
1028}
1029
1030/// Text of a list item after its bullet marker.
1031fn item_body<'a>(item: &'a str, bullet: &Bullet) -> &'a str {
1032    match bullet {
1033        Bullet::Dash | Bullet::Plus => item[1..].trim_start(),
1034        Bullet::Ordered(_) => {
1035            // Skip digits then the `.`/`)` terminator.
1036            let after_digits = item.trim_start_matches(|c: char| c.is_ascii_digit());
1037            after_digits
1038                .strip_prefix('.')
1039                .or_else(|| after_digits.strip_prefix(')'))
1040                .unwrap_or(after_digits)
1041                .trim_start()
1042        }
1043    }
1044}
1045
1046/// Detect a leading `[@N]` counter on a list item, which sets its number explicitly.
1047fn split_counter(text: &str) -> (Option<u32>, &str) {
1048    let Some(rest) = text.strip_prefix("[@") else {
1049        return (None, text);
1050    };
1051    let Some(end) = rest.find(']') else {
1052        return (None, text);
1053    };
1054    match rest[..end].parse::<u32>() {
1055        Ok(n) => (Some(n), rest[end + 1..].trim_start()),
1056        Err(_) => (None, text),
1057    }
1058}
1059
1060/// Detect a leading `[ ]`/`[X]`/`[-]` checkbox on a list item.
1061fn split_checkbox(text: &str) -> (Option<Checkbox>, &str) {
1062    let bytes = text.as_bytes();
1063    if bytes.len() >= 3 && bytes[0] == b'[' && bytes[2] == b']' {
1064        let cb = match bytes[1] {
1065            b' ' => Some(Checkbox::Off),
1066            b'X' | b'x' => Some(Checkbox::On),
1067            b'-' => Some(Checkbox::Trans),
1068            _ => None,
1069        };
1070        if let Some(cb) = cb {
1071            return (Some(cb), text[3..].trim_start());
1072        }
1073    }
1074    (None, text)
1075}
1076
1077// ---------------------------------------------------------------------------
1078// Line predicates / small parsers
1079// ---------------------------------------------------------------------------
1080
1081fn block_begin(line: &str) -> Option<(String, String)> {
1082    let t = line.trim_start();
1083    let upper = t.to_ascii_uppercase();
1084    let rest_upper = upper.strip_prefix("#+BEGIN_")?;
1085    let kind_len = rest_upper
1086        .find(char::is_whitespace)
1087        .unwrap_or(rest_upper.len());
1088    // Index back into the original-case string past "#+BEGIN_".
1089    let base = t.len() - rest_upper.len();
1090    let kind = t[base..base + kind_len].to_string();
1091    let after = t[base + kind_len..].trim().to_string();
1092    Some((kind, after))
1093}
1094
1095fn is_block_end(line: &str) -> bool {
1096    line.trim_start().to_ascii_uppercase().starts_with("#+END_")
1097}
1098
1099/// Does this line close a block of exactly `kind`?
1100fn is_block_end_of(line: &str, kind: &str) -> bool {
1101    let upper = line.trim().to_ascii_uppercase();
1102    match upper.strip_prefix("#+END_") {
1103        Some(rest) => rest.trim() == kind.to_ascii_uppercase(),
1104        None => false,
1105    }
1106}
1107
1108fn parse_src_header(after: &str) -> (Option<String>, BlockParams) {
1109    let mut parts = after.splitn(2, char::is_whitespace);
1110    let lang = parts.next().filter(|s| !s.is_empty()).map(|s| s.to_string());
1111    let params = BlockParams {
1112        raw: parts.next().unwrap_or("").trim().to_string(),
1113    };
1114    (lang, params)
1115}
1116
1117/// `#+KEY: value`, excluding `#+BEGIN_`/`#+END_` block delimiters.
1118fn keyword_kv(line: &str) -> Option<(String, String)> {
1119    let t = line.trim_start();
1120    let rest = t.strip_prefix("#+")?;
1121    if rest.to_ascii_uppercase().starts_with("BEGIN_")
1122        || rest.to_ascii_uppercase().starts_with("END_")
1123    {
1124        return None;
1125    }
1126    let colon = rest.find(':')?;
1127    let key = rest[..colon].trim().to_string();
1128    if key.is_empty() {
1129        return None;
1130    }
1131    let value = rest[colon + 1..].trim().to_string();
1132    Some((key, value))
1133}
1134
1135fn is_rule(line: &str) -> bool {
1136    let t = line.trim();
1137    t.len() >= 5 && t.chars().all(|c| c == '-')
1138}
1139
1140fn is_drawer_begin(t: &str) -> bool {
1141    if !t.starts_with(':') || !t.ends_with(':') || t.len() < 3 {
1142        return false;
1143    }
1144    let inner = &t[1..t.len() - 1];
1145    !inner.is_empty()
1146        && inner
1147            .chars()
1148            .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
1149}
1150
1151/// If `t` (already left-trimmed) begins a list item, return its bullet.
1152fn is_list_item(t: &str) -> Option<Bullet> {
1153    let bytes = t.as_bytes();
1154    if bytes.is_empty() {
1155        return None;
1156    }
1157    if (bytes[0] == b'-' || bytes[0] == b'+')
1158        && (bytes.len() == 1 || bytes[1] == b' ')
1159    {
1160        return Some(if bytes[0] == b'-' {
1161            Bullet::Dash
1162        } else {
1163            Bullet::Plus
1164        });
1165    }
1166    let digits: String = t.chars().take_while(|c| c.is_ascii_digit()).collect();
1167    if !digits.is_empty() {
1168        let after = &t[digits.len()..];
1169        if (after.starts_with('.') || after.starts_with(')'))
1170            && (after.len() == 1 || after.as_bytes()[1] == b' ')
1171        {
1172            if let Ok(n) = digits.parse::<u32>() {
1173                return Some(Bullet::Ordered(n));
1174            }
1175        }
1176    }
1177    None
1178}
1179
1180// ---------------------------------------------------------------------------
1181// Inline tokenizer (spec §3.1, R3)
1182// ---------------------------------------------------------------------------
1183
1184/// A recognizer for one kind of inline object: a cheap test for whether it can start at
1185/// `chars[i]`, and the parser that tries it.
1186type ObjectParser = (
1187    fn(&[char], usize) -> bool,
1188    fn(&[char], usize) -> Option<(Object, usize)>,
1189);
1190
1191/// Inline objects in the order they are tried at each position. The first that parses
1192/// wins; text that none of them claims is plain text.
1193const OBJECT_PARSERS: [ObjectParser; 6] = [
1194    (|cs, i| cs[i] == '[' && starts_with_at(cs, i, "[fn:"), try_footnote_ref),
1195    (|cs, i| cs[i] == '[' && cs.get(i + 1) == Some(&'['), try_link),
1196    (|cs, i| matches!(cs[i], '<' | '['), try_timestamp),
1197    (|cs, i| is_scheme_start(cs, i) && boundary_before(cs, i), try_bare_url),
1198    (|cs, i| cs[i] == '\\', try_entity),
1199    (|cs, i| is_marker(cs[i]), try_emphasis),
1200];
1201
1202fn parse_inline_run(chars: &[char]) -> Vec<Object> {
1203    let mut out = Vec::new();
1204    let mut buf = String::new();
1205    let mut i = 0;
1206    while i < chars.len() {
1207        let found = OBJECT_PARSERS
1208            .iter()
1209            .filter(|(applies, _)| applies(chars, i))
1210            .find_map(|(_, parse)| parse(chars, i));
1211        match found {
1212            Some((obj, next)) => {
1213                flush(&mut buf, &mut out);
1214                out.push(obj);
1215                i = next;
1216            }
1217            None => {
1218                buf.push(chars[i]);
1219                i += 1;
1220            }
1221        }
1222    }
1223    flush(&mut buf, &mut out);
1224    out
1225}
1226
1227fn flush(buf: &mut String, out: &mut Vec<Object>) {
1228    if !buf.is_empty() {
1229        out.push(Object::Text(std::mem::take(buf)));
1230    }
1231}
1232
1233fn starts_with_at(chars: &[char], i: usize, needle: &str) -> bool {
1234    let n: Vec<char> = needle.chars().collect();
1235    i + n.len() <= chars.len() && chars[i..i + n.len()] == n[..]
1236}
1237
1238/// A footnote reference: `[fn:LABEL]` (referenced) or `[fn:LABEL:text]` (inline
1239/// definition). Anonymous inline footnotes `[fn::text]` carry an empty label.
1240fn try_footnote_ref(chars: &[char], i: usize) -> Option<(Object, usize)> {
1241    let n = chars.len();
1242    let close = (i + 1..n).find(|&k| chars[k] == ']')?;
1243    let inner: String = chars[i + 1..close].iter().collect();
1244    let rest = inner.strip_prefix("fn:")?;
1245    let (label, inline_objs) = match rest.split_once(':') {
1246        Some((l, txt)) => {
1247            let txt_chars: Vec<char> = txt.chars().collect();
1248            (l.to_string(), Some(parse_inline_run(&txt_chars)))
1249        }
1250        None => (rest.to_string(), None),
1251    };
1252    if label.is_empty() && inline_objs.is_none() {
1253        return None;
1254    }
1255    Some((
1256        Object::FootnoteRef {
1257            label,
1258            inline: inline_objs,
1259        },
1260        close + 1,
1261    ))
1262}
1263
1264/// `[[target]]` or `[[target][description]]`.
1265fn try_link(chars: &[char], i: usize) -> Option<(Object, usize)> {
1266    let n = chars.len();
1267    let mut j = i + 2;
1268    while j + 1 < n {
1269        if chars[j] == ']' && chars[j + 1] == ']' {
1270            let inner = &chars[i + 2..j];
1271            let (target_str, desc) = split_link_inner(inner);
1272            let target = parse_target(&target_str);
1273            let description = desc.map(|d| parse_inline_run(&d));
1274            return Some((Object::Link(Link { target, description }), j + 2));
1275        }
1276        j += 1;
1277    }
1278    None
1279}
1280
1281/// Split `target][desc` into its two halves at the first `][`.
1282fn split_link_inner(inner: &[char]) -> (String, Option<Vec<char>>) {
1283    for k in 0..inner.len().saturating_sub(1) {
1284        if inner[k] == ']' && inner[k + 1] == '[' {
1285            let target: String = inner[..k].iter().collect();
1286            let desc: Vec<char> = inner[k + 2..].to_vec();
1287            return (target, Some(desc));
1288        }
1289    }
1290    (inner.iter().collect(), None)
1291}
1292
1293fn parse_target(s: &str) -> LinkTarget {
1294    if let Some(r) = s.strip_prefix('#') {
1295        LinkTarget::CustomId(r.to_string())
1296    } else if let Some(r) = s.strip_prefix("id:") {
1297        LinkTarget::Id(r.to_string())
1298    } else if let Some(r) = s.strip_prefix('*') {
1299        LinkTarget::Heading(r.to_string())
1300    } else if let Some(r) = s.strip_prefix("file:") {
1301        LinkTarget::File {
1302            path: r.into(),
1303            search: None,
1304        }
1305    } else if is_external_scheme(s) {
1306        LinkTarget::External(s.to_string())
1307    } else {
1308        LinkTarget::File {
1309            path: s.into(),
1310            search: None,
1311        }
1312    }
1313}
1314
1315fn is_external_scheme(s: &str) -> bool {
1316    let s = s.to_ascii_lowercase();
1317    ["http://", "https://", "mailto:", "ftp://", "news:", "tel:"]
1318        .iter()
1319        .any(|p| s.starts_with(p))
1320}
1321
1322fn is_scheme_start(chars: &[char], i: usize) -> bool {
1323    let tail: String = chars[i..].iter().take(8).collect();
1324    let tail = tail.to_ascii_lowercase();
1325    tail.starts_with("http://") || tail.starts_with("https://") || tail.starts_with("mailto:")
1326}
1327
1328/// A bare URL in running text, e.g. `https://example.com`.
1329fn try_bare_url(chars: &[char], i: usize) -> Option<(Object, usize)> {
1330    let n = chars.len();
1331    let mut j = i;
1332    while j < n {
1333        let c = chars[j];
1334        if c.is_whitespace() || matches!(c, '<' | '>' | '[' | ']' | '"' | '{' | '}') {
1335            break;
1336        }
1337        j += 1;
1338    }
1339    // Trim trailing sentence punctuation that is unlikely to be part of the URL.
1340    while j > i && matches!(chars[j - 1], '.' | ',' | ';' | ':' | '!' | '?' | ')') {
1341        j -= 1;
1342    }
1343    if j <= i {
1344        return None;
1345    }
1346    let url: String = chars[i..j].iter().collect();
1347    Some((
1348        Object::Link(Link {
1349            target: LinkTarget::External(url),
1350            description: None,
1351        }),
1352        j,
1353    ))
1354}
1355
1356// ---------------------------------------------------------------------------
1357// Timestamps
1358// ---------------------------------------------------------------------------
1359
1360/// An org timestamp: `<2024-01-15 Mon>` (active) or `[2024-01-15 Mon]` (inactive), with
1361/// an optional `HH:MM` time, an optional `HH:MM-HH:MM` same-day range, and an optional
1362/// `--`-joined second stamp for a multi-day range.
1363fn try_timestamp(chars: &[char], i: usize) -> Option<(Object, usize)> {
1364    let active = chars[i] == '<';
1365    let (start, same_day_end, has_time, mut next) = parse_stamp(chars, i)?;
1366    let mut end = same_day_end;
1367    if end.is_none() && starts_with_at(chars, next, "--") {
1368        // A range's two halves must agree on activeness, or it is two adjacent stamps.
1369        if chars.get(next + 2) == Some(&chars[i]) {
1370            if let Some((stamp_end, _, _, after)) = parse_stamp(chars, next + 2) {
1371                end = Some(stamp_end);
1372                next = after;
1373            }
1374        }
1375    }
1376    Some((
1377        Object::Timestamp(Timestamp {
1378            active,
1379            start,
1380            end,
1381            has_time,
1382        }),
1383        next,
1384    ))
1385}
1386
1387/// One bracketed stamp → `(start, same-day end, has_time, index past the bracket)`.
1388/// Day names (`Mon`) and repeater/warning cookies (`+1w`, `-2d`) are recognized and
1389/// discarded — they carry no export meaning (§"Not supported": agenda semantics).
1390fn parse_stamp(
1391    chars: &[char],
1392    i: usize,
1393) -> Option<(NaiveDateTime, Option<NaiveDateTime>, bool, usize)> {
1394    let open = *chars.get(i)?;
1395    let close = match open {
1396        '<' => '>',
1397        '[' => ']',
1398        _ => return None,
1399    };
1400    let end = (i + 1..chars.len()).find(|&k| chars[k] == close)?;
1401    let body: String = chars[i + 1..end].iter().collect();
1402    let mut parts = body.split_whitespace();
1403    let date = NaiveDate::parse_from_str(parts.next()?, "%Y-%m-%d").ok()?;
1404
1405    let mut has_time = false;
1406    let mut start_time = NaiveTime::MIN;
1407    let mut end_time = None;
1408    for part in parts {
1409        if let Some((from, to)) = parse_time_spec(part) {
1410            has_time = true;
1411            start_time = from;
1412            end_time = to;
1413        }
1414    }
1415    Some((
1416        date.and_time(start_time),
1417        end_time.map(|t| date.and_time(t)),
1418        has_time,
1419        end + 1,
1420    ))
1421}
1422
1423/// `HH:MM` or `HH:MM-HH:MM`.
1424fn parse_time_spec(s: &str) -> Option<(NaiveTime, Option<NaiveTime>)> {
1425    let (from, to) = match s.split_once('-') {
1426        Some((a, b)) => (a, Some(b)),
1427        None => (s, None),
1428    };
1429    let from = NaiveTime::parse_from_str(from, "%H:%M").ok()?;
1430    let to = match to {
1431        Some(b) => Some(NaiveTime::parse_from_str(b, "%H:%M").ok()?),
1432        None => None,
1433    };
1434    Some((from, to))
1435}
1436
1437fn is_marker(c: char) -> bool {
1438    matches!(c, '*' | '/' | '_' | '+' | '=' | '~')
1439}
1440
1441fn pre_ok(prev: Option<char>) -> bool {
1442    match prev {
1443        None => true,
1444        Some(c) => c.is_whitespace() || matches!(c, '-' | '(' | '{' | '\'' | '"'),
1445    }
1446}
1447
1448fn post_ok(next: Option<char>) -> bool {
1449    match next {
1450        None => true,
1451        Some(c) => {
1452            c.is_whitespace() || matches!(c, '-' | '.' | ',' | ';' | ':' | '!' | '?' | ')' | '}' | '[' | '"' | '\'')
1453        }
1454    }
1455}
1456
1457/// Org emphasis with pre/post-char boundary rules. `=`/`~` carry literal content.
1458fn try_emphasis(chars: &[char], i: usize) -> Option<(Object, usize)> {
1459    let n = chars.len();
1460    let m = chars[i];
1461    let prev = if i == 0 { None } else { Some(chars[i - 1]) };
1462    if !pre_ok(prev) {
1463        return None;
1464    }
1465    if i + 1 >= n {
1466        return None;
1467    }
1468    // Org's body-character rule: the character after the opening marker may not be
1469    // whitespace, a comma or a quote. It *may* be another marker, which is what makes
1470    // `~~/.config/emacs~` verbatim for a path that starts with `~`.
1471    if !body_char_ok(chars[i + 1]) {
1472        return None;
1473    }
1474    let mut j = i + 1;
1475    while j < n {
1476        if chars[j] == m && j > i + 1 {
1477            let before = chars[j - 1];
1478            let next = chars.get(j + 1).copied();
1479            if body_char_ok(before) && post_ok(next) {
1480                let inner = &chars[i + 1..j];
1481                let obj = match m {
1482                    '=' => Object::Verbatim(inner.iter().collect()),
1483                    '~' => Object::Code(inner.iter().collect()),
1484                    '*' => Object::Bold(parse_inline_run(inner)),
1485                    '/' => Object::Italic(parse_inline_run(inner)),
1486                    '_' => Object::Underline(parse_inline_run(inner)),
1487                    '+' => Object::StrikeThrough(parse_inline_run(inner)),
1488                    _ => unreachable!(),
1489                };
1490                return Some((obj, j + 1));
1491            }
1492        }
1493        j += 1;
1494    }
1495    None
1496}
1497
1498/// An org entity: `\alpha`, closed by end of text, `{}`, or any non-letter — which is
1499/// what stops `\alphabet` from being a Greek letter followed by "bet".
1500///
1501/// Only names org actually knows become entities; anything else stays the literal text
1502/// the author typed, since a typo should look like a typo rather than vanish.
1503fn try_entity(chars: &[char], i: usize) -> Option<(Object, usize)> {
1504    let mut j = i + 1;
1505    while chars.get(j).is_some_and(|c| c.is_ascii_alphabetic()) {
1506        j += 1;
1507    }
1508    if j == i + 1 {
1509        return None;
1510    }
1511    let name: String = chars[i + 1..j].iter().collect();
1512    crate::entities::lookup(&name)?;
1513    // `{}` is the explicit terminator and is consumed; anything else is left in place.
1514    let next = if chars.get(j) == Some(&'{') && chars.get(j + 1) == Some(&'}') {
1515        j + 2
1516    } else {
1517        j
1518    };
1519    Some((Object::Entity(name), next))
1520}
1521
1522/// May this character sit directly inside an emphasis marker?
1523///
1524/// Only whitespace is forbidden — org's border class is `[:space:]`. A quote may open a
1525/// body, which is what makes `="proxied":false=` verbatim, and `=SPC m '=` may close on
1526/// an apostrophe. The marker character itself is allowed too, so `~~/.config/emacs~` is a
1527/// path that starts with a tilde.
1528fn body_char_ok(c: char) -> bool {
1529    !c.is_whitespace()
1530}
1531
1532fn boundary_before(chars: &[char], i: usize) -> bool {
1533    if i == 0 {
1534        return true;
1535    }
1536    let c = chars[i - 1];
1537    c.is_whitespace() || matches!(c, '(' | '[' | '{' | '<' | '"' | '\'')
1538}