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//! v0.1 scope (the CORE subset): headings + nesting, property drawers on headings,
13//! paragraphs, plain lists (unordered + ordered) with checkboxes, source blocks, and
14//! inline markup (bold/italic/underline/strike/verbatim/code, links, bare URLs).
15//! Out of scope and left graceful (parsed-and-ignored, never crashing): tables,
16//! footnotes, timestamps, TODO keywords, non-SRC blocks (kept verbatim as example
17//! blocks), generic drawers other than PROPERTIES.
18
19use camino::Utf8Path;
20
21use crate::model::{
22 BlockParams, Bullet, Checkbox, ContentHash, Document, Element, Heading, Keywords, Link,
23 LinkTarget, List, ListItem, ListKind, Object, Properties, Section, Table, TableRow,
24};
25
26#[derive(Debug, thiserror::Error)]
27pub enum ParseError {
28 #[error("parse error at line {line}: {message}")]
29 At { line: usize, message: String },
30}
31
32/// Classified lines produced by the first pass (spec §3.1).
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum Line {
35 Heading,
36 BlockBegin { kind: String },
37 BlockEnd,
38 ListItem,
39 TableRow,
40 Keyword,
41 DrawerBegin,
42 DrawerEnd,
43 Rule,
44 Blank,
45 Text,
46}
47
48/// First pass: classify each raw line. Context-free per line.
49pub fn line_lexer(source: &str) -> Vec<Line> {
50 source.lines().map(classify_line).collect()
51}
52
53fn classify_line(line: &str) -> Line {
54 if line.trim().is_empty() {
55 return Line::Blank;
56 }
57 if heading_level(line).is_some() {
58 return Line::Heading;
59 }
60 let t = line.trim_start();
61 let upper = t.to_ascii_uppercase();
62 if let Some(rest) = upper.strip_prefix("#+BEGIN_") {
63 let kind = rest.split_whitespace().next().unwrap_or("").to_string();
64 return Line::BlockBegin { kind };
65 }
66 if upper.starts_with("#+END_") {
67 return Line::BlockEnd;
68 }
69 if keyword_kv(line).is_some() {
70 return Line::Keyword;
71 }
72 if is_rule(line) {
73 return Line::Rule;
74 }
75 if t.eq_ignore_ascii_case(":END:") {
76 return Line::DrawerEnd;
77 }
78 if is_drawer_begin(t) {
79 return Line::DrawerBegin;
80 }
81 if is_list_item(t).is_some() {
82 return Line::ListItem;
83 }
84 if t.starts_with('|') {
85 return Line::TableRow;
86 }
87 Line::Text
88}
89
90/// blake3 of raw source bytes — the content hash that drives re-parse decisions (spec §4.1).
91pub fn content_hash(bytes: &[u8]) -> ContentHash {
92 ContentHash(*blake3::hash(bytes).as_bytes())
93}
94
95/// Parse one source file into a [`Document`]. Pure over `(path, source)`.
96pub fn parse(path: &Utf8Path, source: &str) -> Result<Document, ParseError> {
97 let content_hash = content_hash(source.as_bytes());
98 let lines: Vec<&str> = source.lines().collect();
99 let classes = line_lexer(source);
100
101 let mut keywords = Keywords::default();
102 let mut root = Section {
103 heading: None,
104 content: Vec::new(),
105 children: Vec::new(),
106 };
107
108 let heading_idxs: Vec<usize> = classes
109 .iter()
110 .enumerate()
111 .filter(|(_, c)| **c == Line::Heading)
112 .map(|(i, _)| i)
113 .collect();
114 let first = heading_idxs.first().copied().unwrap_or(lines.len());
115
116 // Preamble: document-level keywords are lifted into `keywords`; the remaining
117 // lines become the root section's block content.
118 {
119 let mut body: Vec<&str> = Vec::new();
120 for (l, c) in lines[..first].iter().zip(&classes[..first]) {
121 if *c == Line::Keyword {
122 if let Some((k, v)) = keyword_kv(l) {
123 keywords.entries.push((k, v));
124 }
125 } else {
126 body.push(l);
127 }
128 }
129 root.content = parse_elements(&body);
130 }
131
132 // Each heading segment runs from its own line up to (but excluding) the next heading.
133 let mut flat: Vec<(u8, Section)> = Vec::new();
134 for (k, &h_idx) in heading_idxs.iter().enumerate() {
135 let end = heading_idxs.get(k + 1).copied().unwrap_or(lines.len());
136 let heading = parse_heading(lines[h_idx]);
137 let level = heading.level;
138 let (heading, content) = parse_section_body(heading, &lines[h_idx + 1..end]);
139 flat.push((
140 level,
141 Section {
142 heading: Some(heading),
143 content,
144 children: Vec::new(),
145 },
146 ));
147 }
148
149 let mut pos = 0;
150 root.children = build_children(&mut flat, &mut pos, 0);
151
152 Ok(Document {
153 source_path: path.to_owned(),
154 content_hash,
155 keywords,
156 root,
157 })
158}
159
160/// Fold the flat `(level, section)` list into org's nested hierarchy by level.
161fn build_children(flat: &mut [(u8, Section)], pos: &mut usize, parent_level: u8) -> Vec<Section> {
162 let mut children = Vec::new();
163 while *pos < flat.len() {
164 let level = flat[*pos].0;
165 if level <= parent_level {
166 break;
167 }
168 let mut section = std::mem::replace(&mut flat[*pos].1, empty_section());
169 *pos += 1;
170 section.children = build_children(flat, pos, level);
171 children.push(section);
172 }
173 children
174}
175
176fn empty_section() -> Section {
177 Section {
178 heading: None,
179 content: Vec::new(),
180 children: Vec::new(),
181 }
182}
183
184/// Second-tier: scan an element's text into inline objects, applying org's
185/// pre/post-char emphasis rules (spec §3.1, R3 — the highest-divergence area).
186pub fn inline(text: &str) -> Vec<Object> {
187 let chars: Vec<char> = text.chars().collect();
188 parse_inline_run(&chars)
189}
190
191// ---------------------------------------------------------------------------
192// Headings
193// ---------------------------------------------------------------------------
194
195/// `*`-prefixed heading depth, or `None` if the line is not a heading.
196fn heading_level(line: &str) -> Option<u8> {
197 if !line.starts_with('*') {
198 return None;
199 }
200 let stars = line.chars().take_while(|c| *c == '*').count();
201 let after = &line[stars..];
202 if after.starts_with(' ') || after.is_empty() {
203 Some(stars.min(u8::MAX as usize) as u8)
204 } else {
205 None
206 }
207}
208
209fn parse_heading(line: &str) -> Heading {
210 let level = heading_level(line).unwrap_or(1);
211 let rest = line[level as usize..].trim();
212 let (title_str, tags) = split_tags(rest);
213 Heading {
214 level,
215 todo: None, // TODO keywords: out of scope for v0.1.
216 priority: None, // priorities: out of scope for v0.1.
217 title: inline(title_str.trim()),
218 tags,
219 properties: Properties::default(),
220 id: None,
221 custom_id: None,
222 }
223}
224
225/// Split a trailing `:tag1:tag2:` cluster off the heading text.
226fn split_tags(rest: &str) -> (&str, Vec<String>) {
227 let trimmed = rest.trim_end();
228 if !trimmed.ends_with(':') {
229 return (rest, Vec::new());
230 }
231 let start = match trimmed.rfind(char::is_whitespace) {
232 Some(i) => i + 1,
233 None => 0,
234 };
235 let candidate = &trimmed[start..];
236 if is_tag_cluster(candidate) {
237 let tags = candidate
238 .split(':')
239 .filter(|s| !s.is_empty())
240 .map(|s| s.to_string())
241 .collect();
242 (&trimmed[..start], tags)
243 } else {
244 (rest, Vec::new())
245 }
246}
247
248/// A `:a:b:c:` cluster: colon-delimited, non-empty tag names, colon-bounded.
249fn is_tag_cluster(s: &str) -> bool {
250 if !s.starts_with(':') || !s.ends_with(':') || s.len() < 3 {
251 return false;
252 }
253 let inner = &s[1..s.len() - 1];
254 !inner.is_empty()
255 && inner.split(':').all(|part| {
256 !part.is_empty()
257 && part
258 .chars()
259 .all(|c| c.is_alphanumeric() || matches!(c, '_' | '@' | '#' | '%'))
260 })
261}
262
263// ---------------------------------------------------------------------------
264// Section body: property drawer + block content
265// ---------------------------------------------------------------------------
266
267fn parse_section_body(mut heading: Heading, body: &[&str]) -> (Heading, Vec<Element>) {
268 let mut idx = 0;
269 while idx < body.len() && body[idx].trim().is_empty() {
270 idx += 1;
271 }
272 if idx < body.len() && body[idx].trim().eq_ignore_ascii_case(":PROPERTIES:") {
273 idx += 1;
274 while idx < body.len() {
275 let t = body[idx].trim();
276 if t.eq_ignore_ascii_case(":END:") {
277 idx += 1;
278 break;
279 }
280 if let Some((k, v)) = parse_property(t) {
281 if k.eq_ignore_ascii_case("CUSTOM_ID") {
282 heading.custom_id = Some(v.clone());
283 } else if k.eq_ignore_ascii_case("ID") {
284 heading.id = Some(v.clone());
285 }
286 heading.properties.entries.push((k, v));
287 }
288 idx += 1;
289 }
290 }
291 let content = parse_elements(&body[idx..]);
292 (heading, content)
293}
294
295/// `:KEY: value` inside a drawer.
296fn parse_property(line: &str) -> Option<(String, String)> {
297 let line = line.trim();
298 let line = line.strip_prefix(':')?;
299 let end = line.find(':')?;
300 let key = line[..end].trim().to_string();
301 if key.is_empty() {
302 return None;
303 }
304 let value = line[end + 1..].trim().to_string();
305 Some((key, value))
306}
307
308// ---------------------------------------------------------------------------
309// Block-level element builder
310// ---------------------------------------------------------------------------
311
312fn parse_elements(lines: &[&str]) -> Vec<Element> {
313 let mut out = Vec::new();
314 let mut i = 0;
315 while i < lines.len() {
316 let line = lines[i];
317 if line.trim().is_empty() {
318 i += 1;
319 continue;
320 }
321 if let Some((kind, after)) = block_begin(line) {
322 let mut j = i + 1;
323 let mut inner = Vec::new();
324 while j < lines.len() && !is_block_end(lines[j]) {
325 inner.push(lines[j]);
326 j += 1;
327 }
328 let code = inner.join("\n");
329 if kind.eq_ignore_ascii_case("SRC") {
330 let (lang, params) = parse_src_header(&after);
331 out.push(Element::SrcBlock { lang, params, code });
332 } else {
333 // Non-SRC blocks (quote/example/center/export) are kept verbatim for
334 // v0.1 rather than richly modeled — see module scope note.
335 out.push(Element::ExampleBlock(code));
336 }
337 i = if j < lines.len() { j + 1 } else { j };
338 continue;
339 }
340 if is_rule(line) {
341 out.push(Element::HorizontalRule);
342 i += 1;
343 continue;
344 }
345 if let Some((key, value)) = keyword_kv(line) {
346 out.push(Element::Keyword { key, value });
347 i += 1;
348 continue;
349 }
350 if line.trim_start().starts_with('|') {
351 let (table, next) = parse_table(lines, i);
352 out.push(Element::Table(table));
353 i = next;
354 continue;
355 }
356 if let Some((label, first_rest)) = footnote_def_label(line) {
357 let (def, next) = parse_footnote_def(lines, i, label, first_rest);
358 out.push(def);
359 i = next;
360 continue;
361 }
362 if is_list_item(line.trim_start()).is_some() {
363 let (list, next) = parse_list(lines, i);
364 out.push(Element::List(list));
365 i = next;
366 continue;
367 }
368 // Paragraph: gather consecutive soft-wrapped text lines.
369 let mut para = Vec::new();
370 while i < lines.len() {
371 let l = lines[i];
372 if l.trim().is_empty() || is_structural(l) {
373 break;
374 }
375 para.push(l.trim());
376 i += 1;
377 }
378 if !para.is_empty() {
379 out.push(Element::Paragraph(inline(¶.join(" "))));
380 }
381 }
382 out
383}
384
385/// Is this line the start of a non-paragraph construct?
386fn is_structural(line: &str) -> bool {
387 let t = line.trim_start();
388 block_begin(line).is_some()
389 || is_block_end(line)
390 || is_rule(line)
391 || keyword_kv(line).is_some()
392 || is_list_item(t).is_some()
393 || t.starts_with('|')
394 || footnote_def_label(line).is_some()
395 || heading_level(line).is_some()
396}
397
398// ---------------------------------------------------------------------------
399// Tables (spec §1 IN; `#+TBLFM:` formulas are parse-and-ignored via keyword_kv)
400// ---------------------------------------------------------------------------
401
402/// Consume a run of consecutive `|`-prefixed lines into a [`Table`]. Rule rows
403/// (`|---+---|`) are preserved as [`TableRow::Rule`] so the renderer can locate the
404/// header band.
405fn parse_table(lines: &[&str], start: usize) -> (Table, usize) {
406 let mut rows = Vec::new();
407 let mut i = start;
408 while i < lines.len() {
409 let t = lines[i].trim_start();
410 if !t.starts_with('|') {
411 break;
412 }
413 if is_table_rule(t) {
414 rows.push(TableRow::Rule);
415 } else {
416 rows.push(TableRow::Cells(parse_table_cells(t)));
417 }
418 i += 1;
419 }
420 (Table { rows }, i)
421}
422
423/// A rule row: only `|`, `-`, `+`, whitespace, and at least one `-`.
424fn is_table_rule(t: &str) -> bool {
425 t.starts_with('|')
426 && t.contains('-')
427 && t.chars().all(|c| matches!(c, '|' | '-' | '+' | ' '))
428}
429
430fn parse_table_cells(t: &str) -> Vec<Vec<Object>> {
431 let inner = t.trim().trim_start_matches('|').trim_end_matches('|');
432 inner.split('|').map(|cell| inline(cell.trim())).collect()
433}
434
435// ---------------------------------------------------------------------------
436// Footnote definitions (spec §1 IN; inline refs handled in the inline tokenizer)
437// ---------------------------------------------------------------------------
438
439/// A footnote *definition* line: `[fn:LABEL] text...`. Returns the label and the
440/// remainder on the same line. `[fn:LABEL:inline]` (a colon inside the label span) is
441/// an inline reference, not a definition, so it is rejected here.
442fn footnote_def_label(line: &str) -> Option<(String, String)> {
443 let t = line.trim_start();
444 let r = t.strip_prefix("[fn:")?;
445 let end = r.find(']')?;
446 let label = &r[..end];
447 if label.is_empty() || label.contains(':') {
448 return None;
449 }
450 Some((label.to_string(), r[end + 1..].trim_start().to_string()))
451}
452
453/// Gather a footnote definition's content: the remainder of its opening line plus
454/// following continuation lines up to the next blank/structural/definition line.
455fn parse_footnote_def(
456 lines: &[&str],
457 start: usize,
458 label: String,
459 first_rest: String,
460) -> (Element, usize) {
461 let mut parts: Vec<String> = Vec::new();
462 if !first_rest.is_empty() {
463 parts.push(first_rest);
464 }
465 let mut i = start + 1;
466 while i < lines.len() {
467 let l = lines[i];
468 if l.trim().is_empty() || is_structural(l) {
469 break;
470 }
471 parts.push(l.trim().to_string());
472 i += 1;
473 }
474 let content = if parts.is_empty() {
475 Vec::new()
476 } else {
477 vec![Element::Paragraph(inline(&parts.join(" ")))]
478 };
479 (Element::FootnoteDefinition { label, content }, i)
480}
481
482fn parse_list(lines: &[&str], start: usize) -> (List, usize) {
483 let kind = match is_list_item(lines[start].trim_start()) {
484 Some(Bullet::Ordered(_)) => ListKind::Ordered,
485 _ => ListKind::Unordered,
486 };
487 let mut items = Vec::new();
488 let mut i = start;
489 while i < lines.len() {
490 let t = lines[i].trim_start();
491 let bullet = match is_list_item(t) {
492 Some(b) => b,
493 None => break,
494 };
495 let item_kind = match bullet {
496 Bullet::Ordered(_) => ListKind::Ordered,
497 _ => ListKind::Unordered,
498 };
499 if item_kind != kind {
500 break;
501 }
502 let rest = item_body(t, &bullet);
503 let (checkbox, text) = split_checkbox(rest);
504 items.push(ListItem {
505 bullet,
506 checkbox,
507 term: None, // description lists: out of scope for v0.1.
508 content: vec![Element::Paragraph(inline(text.trim()))],
509 });
510 i += 1;
511 }
512 (List { kind, items }, i)
513}
514
515/// Text of a list item after its bullet marker.
516fn item_body<'a>(item: &'a str, bullet: &Bullet) -> &'a str {
517 match bullet {
518 Bullet::Dash | Bullet::Plus => item[1..].trim_start(),
519 Bullet::Ordered(_) => {
520 // Skip digits then the `.`/`)` terminator.
521 let after_digits = item.trim_start_matches(|c: char| c.is_ascii_digit());
522 after_digits
523 .strip_prefix('.')
524 .or_else(|| after_digits.strip_prefix(')'))
525 .unwrap_or(after_digits)
526 .trim_start()
527 }
528 }
529}
530
531/// Detect a leading `[ ]`/`[X]`/`[-]` checkbox on a list item.
532fn split_checkbox(text: &str) -> (Option<Checkbox>, &str) {
533 let bytes = text.as_bytes();
534 if bytes.len() >= 3 && bytes[0] == b'[' && bytes[2] == b']' {
535 let cb = match bytes[1] {
536 b' ' => Some(Checkbox::Off),
537 b'X' | b'x' => Some(Checkbox::On),
538 b'-' => Some(Checkbox::Trans),
539 _ => None,
540 };
541 if let Some(cb) = cb {
542 return (Some(cb), text[3..].trim_start());
543 }
544 }
545 (None, text)
546}
547
548// ---------------------------------------------------------------------------
549// Line predicates / small parsers
550// ---------------------------------------------------------------------------
551
552fn block_begin(line: &str) -> Option<(String, String)> {
553 let t = line.trim_start();
554 let upper = t.to_ascii_uppercase();
555 let rest_upper = upper.strip_prefix("#+BEGIN_")?;
556 let kind_len = rest_upper
557 .find(char::is_whitespace)
558 .unwrap_or(rest_upper.len());
559 // Index back into the original-case string past "#+BEGIN_".
560 let base = t.len() - rest_upper.len();
561 let kind = t[base..base + kind_len].to_string();
562 let after = t[base + kind_len..].trim().to_string();
563 Some((kind, after))
564}
565
566fn is_block_end(line: &str) -> bool {
567 line.trim_start().to_ascii_uppercase().starts_with("#+END_")
568}
569
570fn parse_src_header(after: &str) -> (Option<String>, BlockParams) {
571 let mut parts = after.splitn(2, char::is_whitespace);
572 let lang = parts.next().filter(|s| !s.is_empty()).map(|s| s.to_string());
573 let params = BlockParams {
574 raw: parts.next().unwrap_or("").trim().to_string(),
575 };
576 (lang, params)
577}
578
579/// `#+KEY: value`, excluding `#+BEGIN_`/`#+END_` block delimiters.
580fn keyword_kv(line: &str) -> Option<(String, String)> {
581 let t = line.trim_start();
582 let rest = t.strip_prefix("#+")?;
583 if rest.to_ascii_uppercase().starts_with("BEGIN_")
584 || rest.to_ascii_uppercase().starts_with("END_")
585 {
586 return None;
587 }
588 let colon = rest.find(':')?;
589 let key = rest[..colon].trim().to_string();
590 if key.is_empty() {
591 return None;
592 }
593 let value = rest[colon + 1..].trim().to_string();
594 Some((key, value))
595}
596
597fn is_rule(line: &str) -> bool {
598 let t = line.trim();
599 t.len() >= 5 && t.chars().all(|c| c == '-')
600}
601
602fn is_drawer_begin(t: &str) -> bool {
603 if !t.starts_with(':') || !t.ends_with(':') || t.len() < 3 {
604 return false;
605 }
606 let inner = &t[1..t.len() - 1];
607 !inner.is_empty()
608 && inner
609 .chars()
610 .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
611}
612
613/// If `t` (already left-trimmed) begins a list item, return its bullet.
614fn is_list_item(t: &str) -> Option<Bullet> {
615 let bytes = t.as_bytes();
616 if bytes.is_empty() {
617 return None;
618 }
619 if (bytes[0] == b'-' || bytes[0] == b'+')
620 && (bytes.len() == 1 || bytes[1] == b' ')
621 {
622 return Some(if bytes[0] == b'-' {
623 Bullet::Dash
624 } else {
625 Bullet::Plus
626 });
627 }
628 let digits: String = t.chars().take_while(|c| c.is_ascii_digit()).collect();
629 if !digits.is_empty() {
630 let after = &t[digits.len()..];
631 if (after.starts_with('.') || after.starts_with(')'))
632 && (after.len() == 1 || after.as_bytes()[1] == b' ')
633 {
634 if let Ok(n) = digits.parse::<u32>() {
635 return Some(Bullet::Ordered(n));
636 }
637 }
638 }
639 None
640}
641
642// ---------------------------------------------------------------------------
643// Inline tokenizer (spec §3.1, R3)
644// ---------------------------------------------------------------------------
645
646fn parse_inline_run(chars: &[char]) -> Vec<Object> {
647 let mut out = Vec::new();
648 let mut buf = String::new();
649 let mut i = 0;
650 let n = chars.len();
651 while i < n {
652 let c = chars[i];
653 if c == '[' && starts_with_at(chars, i, "[fn:") {
654 if let Some((obj, next)) = try_footnote_ref(chars, i) {
655 flush(&mut buf, &mut out);
656 out.push(obj);
657 i = next;
658 continue;
659 }
660 }
661 if c == '[' && i + 1 < n && chars[i + 1] == '[' {
662 if let Some((obj, next)) = try_link(chars, i) {
663 flush(&mut buf, &mut out);
664 out.push(obj);
665 i = next;
666 continue;
667 }
668 }
669 if is_scheme_start(chars, i) && boundary_before(chars, i) {
670 if let Some((obj, next)) = try_bare_url(chars, i) {
671 flush(&mut buf, &mut out);
672 out.push(obj);
673 i = next;
674 continue;
675 }
676 }
677 if is_marker(c) {
678 if let Some((obj, next)) = try_emphasis(chars, i) {
679 flush(&mut buf, &mut out);
680 out.push(obj);
681 i = next;
682 continue;
683 }
684 }
685 buf.push(c);
686 i += 1;
687 }
688 flush(&mut buf, &mut out);
689 out
690}
691
692fn flush(buf: &mut String, out: &mut Vec<Object>) {
693 if !buf.is_empty() {
694 out.push(Object::Text(std::mem::take(buf)));
695 }
696}
697
698fn starts_with_at(chars: &[char], i: usize, needle: &str) -> bool {
699 let n: Vec<char> = needle.chars().collect();
700 i + n.len() <= chars.len() && chars[i..i + n.len()] == n[..]
701}
702
703/// A footnote reference: `[fn:LABEL]` (referenced) or `[fn:LABEL:text]` (inline
704/// definition). Anonymous inline footnotes `[fn::text]` carry an empty label.
705fn try_footnote_ref(chars: &[char], i: usize) -> Option<(Object, usize)> {
706 let n = chars.len();
707 let close = (i + 1..n).find(|&k| chars[k] == ']')?;
708 let inner: String = chars[i + 1..close].iter().collect();
709 let rest = inner.strip_prefix("fn:")?;
710 let (label, inline_objs) = match rest.split_once(':') {
711 Some((l, txt)) => {
712 let txt_chars: Vec<char> = txt.chars().collect();
713 (l.to_string(), Some(parse_inline_run(&txt_chars)))
714 }
715 None => (rest.to_string(), None),
716 };
717 if label.is_empty() && inline_objs.is_none() {
718 return None;
719 }
720 Some((
721 Object::FootnoteRef {
722 label,
723 inline: inline_objs,
724 },
725 close + 1,
726 ))
727}
728
729/// `[[target]]` or `[[target][description]]`.
730fn try_link(chars: &[char], i: usize) -> Option<(Object, usize)> {
731 let n = chars.len();
732 let mut j = i + 2;
733 while j + 1 < n {
734 if chars[j] == ']' && chars[j + 1] == ']' {
735 let inner = &chars[i + 2..j];
736 let (target_str, desc) = split_link_inner(inner);
737 let target = parse_target(&target_str);
738 let description = desc.map(|d| parse_inline_run(&d));
739 return Some((Object::Link(Link { target, description }), j + 2));
740 }
741 j += 1;
742 }
743 None
744}
745
746/// Split `target][desc` into its two halves at the first `][`.
747fn split_link_inner(inner: &[char]) -> (String, Option<Vec<char>>) {
748 for k in 0..inner.len().saturating_sub(1) {
749 if inner[k] == ']' && inner[k + 1] == '[' {
750 let target: String = inner[..k].iter().collect();
751 let desc: Vec<char> = inner[k + 2..].to_vec();
752 return (target, Some(desc));
753 }
754 }
755 (inner.iter().collect(), None)
756}
757
758fn parse_target(s: &str) -> LinkTarget {
759 if let Some(r) = s.strip_prefix('#') {
760 LinkTarget::CustomId(r.to_string())
761 } else if let Some(r) = s.strip_prefix("id:") {
762 LinkTarget::Id(r.to_string())
763 } else if let Some(r) = s.strip_prefix('*') {
764 LinkTarget::Heading(r.to_string())
765 } else if let Some(r) = s.strip_prefix("file:") {
766 LinkTarget::File {
767 path: r.into(),
768 search: None,
769 }
770 } else if is_external_scheme(s) {
771 LinkTarget::External(s.to_string())
772 } else {
773 LinkTarget::File {
774 path: s.into(),
775 search: None,
776 }
777 }
778}
779
780fn is_external_scheme(s: &str) -> bool {
781 let s = s.to_ascii_lowercase();
782 ["http://", "https://", "mailto:", "ftp://", "news:", "tel:"]
783 .iter()
784 .any(|p| s.starts_with(p))
785}
786
787fn is_scheme_start(chars: &[char], i: usize) -> bool {
788 let tail: String = chars[i..].iter().take(8).collect();
789 let tail = tail.to_ascii_lowercase();
790 tail.starts_with("http://") || tail.starts_with("https://") || tail.starts_with("mailto:")
791}
792
793/// A bare URL in running text, e.g. `https://example.com`.
794fn try_bare_url(chars: &[char], i: usize) -> Option<(Object, usize)> {
795 let n = chars.len();
796 let mut j = i;
797 while j < n {
798 let c = chars[j];
799 if c.is_whitespace() || matches!(c, '<' | '>' | '[' | ']' | '"' | '{' | '}') {
800 break;
801 }
802 j += 1;
803 }
804 // Trim trailing sentence punctuation that is unlikely to be part of the URL.
805 while j > i && matches!(chars[j - 1], '.' | ',' | ';' | ':' | '!' | '?' | ')') {
806 j -= 1;
807 }
808 if j <= i {
809 return None;
810 }
811 let url: String = chars[i..j].iter().collect();
812 Some((
813 Object::Link(Link {
814 target: LinkTarget::External(url),
815 description: None,
816 }),
817 j,
818 ))
819}
820
821fn is_marker(c: char) -> bool {
822 matches!(c, '*' | '/' | '_' | '+' | '=' | '~')
823}
824
825fn pre_ok(prev: Option<char>) -> bool {
826 match prev {
827 None => true,
828 Some(c) => c.is_whitespace() || matches!(c, '-' | '(' | '{' | '\'' | '"'),
829 }
830}
831
832fn post_ok(next: Option<char>) -> bool {
833 match next {
834 None => true,
835 Some(c) => {
836 c.is_whitespace() || matches!(c, '-' | '.' | ',' | ';' | ':' | '!' | '?' | ')' | '}' | '[' | '"' | '\'')
837 }
838 }
839}
840
841/// Org emphasis with pre/post-char boundary rules. `=`/`~` carry literal content.
842fn try_emphasis(chars: &[char], i: usize) -> Option<(Object, usize)> {
843 let n = chars.len();
844 let m = chars[i];
845 let prev = if i == 0 { None } else { Some(chars[i - 1]) };
846 if !pre_ok(prev) {
847 return None;
848 }
849 if i + 1 >= n {
850 return None;
851 }
852 let after = chars[i + 1];
853 if after.is_whitespace() || after == m {
854 return None;
855 }
856 let mut j = i + 1;
857 while j < n {
858 if chars[j] == m && j > i + 1 {
859 let before = chars[j - 1];
860 let next = chars.get(j + 1).copied();
861 if !before.is_whitespace() && post_ok(next) {
862 let inner = &chars[i + 1..j];
863 let obj = match m {
864 '=' => Object::Verbatim(inner.iter().collect()),
865 '~' => Object::Code(inner.iter().collect()),
866 '*' => Object::Bold(parse_inline_run(inner)),
867 '/' => Object::Italic(parse_inline_run(inner)),
868 '_' => Object::Underline(parse_inline_run(inner)),
869 '+' => Object::StrikeThrough(parse_inline_run(inner)),
870 _ => unreachable!(),
871 };
872 return Some((obj, j + 1));
873 }
874 }
875 j += 1;
876 }
877 None
878}
879
880fn boundary_before(chars: &[char], i: usize) -> bool {
881 if i == 0 {
882 return true;
883 }
884 let c = chars[i - 1];
885 c.is_whitespace() || matches!(c, '(' | '[' | '{' | '<' | '"' | '\'')
886}