krz/orgo

Lightning fast org-mode static site generator.

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

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