krz/orgo

Lightning fast org-mode static site generator.

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

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