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