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