krz/orgo
Lightning fast org-mode static site generator.
clone: git clone https://gitbay.org/krz/orgo.git
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(¶.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}