1use std::fmt;
32use std::ops::Range;
33use std::sync::Arc;
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
40pub struct HeadingLevel(u8);
41
42impl HeadingLevel {
43 pub const H1: Self = Self(1);
45 pub const H2: Self = Self(2);
47
48 pub const fn new(level: u8) -> Option<Self> {
50 if level >= 1 && level <= 6 {
51 Some(Self(level))
52 } else {
53 None
54 }
55 }
56
57 pub const fn get(self) -> u8 {
59 self.0
60 }
61}
62
63impl fmt::Display for HeadingLevel {
64 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65 write!(f, "h{}", self.0)
66 }
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
75pub struct Slug(String);
76
77impl Slug {
78 pub fn as_str(&self) -> &str {
80 &self.0
81 }
82}
83
84impl fmt::Display for Slug {
85 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86 f.write_str(&self.0)
87 }
88}
89
90impl AsRef<str> for Slug {
91 fn as_ref(&self) -> &str {
92 &self.0
93 }
94}
95
96impl PartialEq<str> for Slug {
97 fn eq(&self, other: &str) -> bool {
98 self.0 == other
99 }
100}
101
102#[derive(Debug, Clone, PartialEq, Eq)]
104pub enum LinkTarget {
105 InPackage(String),
108 External(String),
110 Fragment(String),
112}
113
114impl LinkTarget {
115 pub fn as_str(&self) -> &str {
117 match self {
118 Self::InPackage(t) | Self::External(t) | Self::Fragment(t) => t,
119 }
120 }
121}
122
123#[derive(Debug, Clone, PartialEq, Eq)]
125#[non_exhaustive]
126pub struct Link {
127 pub text: String,
129 pub target: LinkTarget,
131}
132
133#[derive(Clone)]
138pub struct Section {
139 source: Arc<str>,
140 level: HeadingLevel,
141 heading: String,
142 slug: Slug,
143 span: Range<usize>,
145 body: Range<usize>,
147 children: Vec<Section>,
148}
149
150impl Section {
151 pub fn level(&self) -> HeadingLevel {
153 self.level
154 }
155
156 pub fn heading(&self) -> &str {
158 &self.heading
159 }
160
161 pub fn slug(&self) -> &Slug {
163 &self.slug
164 }
165
166 pub fn children(&self) -> &[Section] {
168 &self.children
169 }
170
171 pub fn is_leaf(&self) -> bool {
173 self.children.is_empty()
174 }
175
176 pub fn span(&self) -> Range<usize> {
178 self.span.clone()
179 }
180
181 pub fn body(&self) -> &str {
183 &self.source[self.body.clone()]
184 }
185
186 pub fn content(&self) -> &str {
189 &self.source[self.span.clone()]
190 }
191
192 pub fn walk(&self) -> impl Iterator<Item = (usize, &Section)> {
195 Walk { stack: vec![(0, self)] }
196 }
197
198 pub fn links(&self) -> Vec<Link> {
200 collect_links(self.content())
201 }
202}
203
204impl fmt::Debug for Section {
205 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
206 f.debug_struct("Section")
207 .field("level", &self.level)
208 .field("heading", &self.heading)
209 .field("slug", &self.slug)
210 .field("children", &self.children)
211 .finish_non_exhaustive()
212 }
213}
214
215impl PartialEq for Section {
216 fn eq(&self, other: &Self) -> bool {
217 self.level == other.level
218 && self.heading == other.heading
219 && self.slug == other.slug
220 && self.content() == other.content()
221 && self.children == other.children
222 }
223}
224
225impl Eq for Section {}
226
227#[derive(Clone, PartialEq, Eq)]
229pub struct Document {
230 source: Arc<str>,
231 preamble: Range<usize>,
232 sections: Vec<Section>,
233}
234
235impl Document {
236 pub fn parse(source: impl AsRef<str>) -> Self {
239 let source: Arc<str> = Arc::from(source.as_ref());
240 let headings = scan_headings(&source);
241 let preamble_end = headings.first().map_or(source.len(), |h| h.line.start);
242 let sections = build_tree(&source, &headings);
243 Self { source, preamble: 0..preamble_end, sections }
244 }
245
246 pub fn source(&self) -> &str {
248 &self.source
249 }
250
251 pub fn preamble(&self) -> &str {
254 &self.source[self.preamble.clone()]
255 }
256
257 pub fn sections(&self) -> &[Section] {
259 &self.sections
260 }
261
262 pub fn is_flat(&self) -> bool {
264 self.sections.is_empty()
265 }
266
267 pub fn outline(&self) -> impl Iterator<Item = (usize, &Section)> {
277 Walk { stack: self.sections.iter().rev().map(|s| (0, s)).collect() }
278 }
279
280 pub fn find(&self, slug: &str) -> Option<&Section> {
282 self.outline().map(|(_, s)| s).find(|s| s.slug == *slug)
283 }
284
285 pub fn section<'a>(&self, path: impl IntoIterator<Item = &'a str>) -> Option<&Section> {
289 let mut level = &self.sections;
290 let mut found = None;
291 for step in path {
292 let step = step.trim();
293 let next = level.iter().find(|s| s.heading.trim().eq_ignore_ascii_case(step))?;
294 level = &next.children;
295 found = Some(next);
296 }
297 found
298 }
299
300 pub fn links(&self) -> Vec<Link> {
303 collect_links(&self.source)
304 }
305}
306
307impl fmt::Debug for Document {
308 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
309 f.debug_struct("Document")
310 .field("preamble", &self.preamble())
311 .field("sections", &self.sections)
312 .finish()
313 }
314}
315
316impl fmt::Display for Document {
317 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
318 f.write_str(&self.source)
319 }
320}
321
322struct Walk<'a> {
323 stack: Vec<(usize, &'a Section)>,
324}
325
326impl<'a> Iterator for Walk<'a> {
327 type Item = (usize, &'a Section);
328
329 fn next(&mut self) -> Option<Self::Item> {
330 let (depth, section) = self.stack.pop()?;
331 for child in section.children.iter().rev() {
332 self.stack.push((depth + 1, child));
333 }
334 Some((depth, section))
335 }
336}
337
338struct RawHeading {
339 level: HeadingLevel,
340 text: String,
341 slug: String,
342 line: Range<usize>,
344}
345
346fn scan_headings(source: &str) -> Vec<RawHeading> {
349 let mut headings: Vec<RawHeading> = Vec::new();
350 let mut slugs: Vec<String> = Vec::new();
351 let mut fence: Option<(char, usize)> = None;
352 let mut offset = 0;
353 let mut previous: Option<(Range<usize>, String)> = None;
355
356 for line in source.split_inclusive('\n') {
357 let end = offset + line.len();
358 let trimmed = line.trim_end_matches(['\n', '\r']);
359 let content = trimmed.trim_start();
360 let indent = trimmed.len() - content.len();
361
362 if indent <= 3 {
364 if let Some((marker, open_len)) = fence {
365 let run = content.chars().take_while(|c| *c == marker).count();
366 if run >= open_len && content.trim_end().chars().all(|c| c == marker) {
367 fence = None;
368 }
369 offset = end;
370 previous = None;
371 continue;
372 }
373 if let Some(marker) = content.chars().next().filter(|c| *c == '`' || *c == '~') {
374 let run = content.chars().take_while(|c| *c == marker).count();
375 if run >= 3 {
376 fence = Some((marker, run));
377 offset = end;
378 previous = None;
379 continue;
380 }
381 }
382 } else if fence.is_some() {
383 offset = end;
384 previous = None;
385 continue;
386 }
387
388 let mut found: Option<(HeadingLevel, String, Range<usize>)> = None;
389
390 if indent <= 3 && content.starts_with('#') {
391 let hashes = content.chars().take_while(|c| *c == '#').count();
392 let rest = &content[hashes..];
393 if let Some(level) = HeadingLevel::new(hashes as u8) {
394 if rest.is_empty() || rest.starts_with([' ', '\t']) {
395 let text = rest.trim().trim_end_matches('#').trim().to_owned();
396 found = Some((level, text, offset..end));
397 }
398 }
399 } else if indent <= 3 && !content.is_empty() {
400 if let Some(marker) = content.chars().next().filter(|c| *c == '=' || *c == '-') {
402 if content.trim_end().chars().all(|c| c == marker) {
403 if let Some((prev_span, prev_text)) = previous.take() {
404 let level = if marker == '=' { HeadingLevel::H1 } else { HeadingLevel::H2 };
405 found = Some((level, prev_text, prev_span.start..end));
406 }
407 }
408 }
409 }
410
411 match found {
412 Some((level, text, span)) => {
413 let slug = slugify(&text, &slugs);
414 slugs.push(slug.clone());
415 headings.push(RawHeading { level, text, slug, line: span });
416 previous = None;
417 }
418 None => {
419 previous = (!content.is_empty()).then(|| (offset..end, content.trim().to_owned()));
420 }
421 }
422 offset = end;
423 }
424
425 headings
426}
427
428fn build_tree(source: &Arc<str>, headings: &[RawHeading]) -> Vec<Section> {
434 struct Open {
435 level: HeadingLevel,
436 heading: String,
437 slug: String,
438 start: usize,
439 body_start: usize,
440 children: Vec<Section>,
441 }
442
443 let mut roots: Vec<Section> = Vec::new();
444 let mut stack: Vec<Open> = Vec::new();
445
446 let close = |open: Open, end: usize, source: &Arc<str>| Section {
447 source: Arc::clone(source),
448 level: open.level,
449 heading: open.heading,
450 slug: Slug(open.slug),
451 span: open.start..end,
452 body: open.body_start..open.children.first().map_or(end, |c| c.span.start),
455 children: open.children,
456 };
457
458 for head in headings {
459 while stack.last().is_some_and(|open| open.level >= head.level) {
460 let open = stack.pop().expect("checked by the condition");
461 let section = close(open, head.line.start, source);
462 match stack.last_mut() {
463 Some(parent) => parent.children.push(section),
464 None => roots.push(section),
465 }
466 }
467 stack.push(Open {
468 level: head.level,
469 heading: head.text.clone(),
470 slug: head.slug.clone(),
471 start: head.line.start,
472 body_start: head.line.end,
473 children: Vec::new(),
474 });
475 }
476
477 while let Some(open) = stack.pop() {
478 let section = close(open, source.len(), source);
479 match stack.last_mut() {
480 Some(parent) => parent.children.push(section),
481 None => roots.push(section),
482 }
483 }
484
485 roots
486}
487
488fn slugify(text: &str, taken: &[String]) -> String {
491 let mut base = String::with_capacity(text.len());
492 let mut pending_hyphen = false;
493 for c in text.chars() {
494 if c.is_alphanumeric() {
495 if pending_hyphen && !base.is_empty() {
496 base.push('-');
497 }
498 pending_hyphen = false;
499 base.extend(c.to_lowercase());
500 } else {
501 pending_hyphen = true;
502 }
503 }
504 if base.is_empty() {
505 base.push_str("section");
506 }
507 if !taken.contains(&base) {
508 return base;
509 }
510 (1..)
511 .map(|n| format!("{base}-{n}"))
512 .find(|candidate| !taken.contains(candidate))
513 .expect("an unused suffix always exists")
514}
515
516fn collect_links(source: &str) -> Vec<Link> {
518 let mut links = Vec::new();
519 let mut fence: Option<(char, usize)> = None;
520
521 for line in source.split_inclusive('\n') {
522 let content = line.trim_end_matches(['\n', '\r']).trim_start();
523 if let Some((marker, open_len)) = fence {
524 let run = content.chars().take_while(|c| *c == marker).count();
525 if run >= open_len && content.trim_end().chars().all(|c| c == marker) {
526 fence = None;
527 }
528 continue;
529 }
530 if let Some(marker) = content.chars().next().filter(|c| *c == '`' || *c == '~') {
531 if content.chars().take_while(|c| *c == marker).count() >= 3 {
532 fence = Some((marker, content.chars().take_while(|c| *c == marker).count()));
533 continue;
534 }
535 }
536 links.extend(links_in_line(content));
537 }
538 links
539}
540
541fn links_in_line(line: &str) -> Vec<Link> {
542 let chars: Vec<char> = line.chars().collect();
543 let mut links = Vec::new();
544 let mut in_code_span = false;
545 let mut i = 0;
546 while i < chars.len() {
547 match chars[i] {
548 '`' => in_code_span = !in_code_span,
549 '[' if !in_code_span && (i == 0 || chars[i - 1] != '\\') => {
550 if let Some((text, target, next)) = parse_link(&chars, i) {
551 links.push(Link { text, target: classify(target) });
552 i = next;
553 continue;
554 }
555 }
556 _ => {}
557 }
558 i += 1;
559 }
560 links
561}
562
563fn parse_link(chars: &[char], open: usize) -> Option<(String, String, usize)> {
566 let mut depth = 0usize;
567 let mut close = None;
568 for (offset, c) in chars.iter().enumerate().skip(open) {
569 match c {
570 '[' => depth += 1,
571 ']' => {
572 depth -= 1;
573 if depth == 0 {
574 close = Some(offset);
575 break;
576 }
577 }
578 _ => {}
579 }
580 }
581 let close = close?;
582 if chars.get(close + 1) != Some(&'(') {
583 return None;
584 }
585 let target_end = chars[close + 2..].iter().position(|c| *c == ')')? + close + 2;
586 let text: String = chars[open + 1..close].iter().collect();
587 let raw_target: String = chars[close + 2..target_end].iter().collect();
588 let target = raw_target
590 .split_once(char::is_whitespace)
591 .map_or(raw_target.as_str(), |(t, _)| t)
592 .trim_matches(['<', '>'])
593 .to_owned();
594 if target.is_empty() {
595 return None;
596 }
597 Some((text, target, target_end + 1))
598}
599
600fn classify(target: String) -> LinkTarget {
601 if let Some(fragment) = target.strip_prefix('#') {
602 return LinkTarget::Fragment(fragment.to_owned());
603 }
604 let has_scheme = target.split_once(':').is_some_and(|(scheme, _)| {
605 scheme.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
606 && scheme.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
607 });
608 if has_scheme || target.starts_with("//") {
609 LinkTarget::External(target)
610 } else {
611 LinkTarget::InPackage(target)
612 }
613}
614
615#[cfg(test)]
616mod tests {
617 use super::*;
618
619 #[test]
620 fn nesting_and_spans() {
621 let doc = Document::parse(
622 "Preamble.\n\n# One\n\nalpha\n\n## One-A\n\nbeta\n\n## One-B\n\ngamma\n\n# Two\n\ndelta\n",
623 );
624 assert_eq!(doc.preamble().trim(), "Preamble.");
625 assert_eq!(doc.sections().len(), 2);
626
627 let one = &doc.sections()[0];
628 assert_eq!(one.heading(), "One");
629 assert_eq!(one.body().trim(), "alpha");
630 assert!(one.content().contains("beta") && one.content().contains("gamma"));
631 assert!(!one.content().contains("delta"));
632 assert_eq!(one.children().len(), 2);
633 assert_eq!(one.children()[0].body().trim(), "beta");
634
635 let two = &doc.sections()[1];
636 assert_eq!(two.heading(), "Two");
637 assert_eq!(two.body().trim(), "delta");
638 assert!(two.is_leaf());
639 }
640
641 #[test]
642 fn headings_inside_fences_are_inert() {
643 let doc = Document::parse(
644 "# Real\n\n```sh\n# not a heading\necho hi\n```\n\n~~~\n## also not\n~~~\n\n## Real Two\n",
645 );
646 let headings: Vec<_> = doc.outline().map(|(_, s)| s.heading()).collect();
647 assert_eq!(headings, ["Real", "Real Two"]);
648 }
649
650 #[test]
651 fn setext_headings() {
652 let doc = Document::parse("Title\n=====\n\nbody\n\nSub\n---\n\nmore\n");
653 assert_eq!(doc.sections().len(), 1);
654 let title = &doc.sections()[0];
655 assert_eq!(title.heading(), "Title");
656 assert_eq!(title.level(), HeadingLevel::H1);
657 assert_eq!(title.children()[0].heading(), "Sub");
658 assert_eq!(title.children()[0].level(), HeadingLevel::H2);
659 }
660
661 #[test]
662 fn navigation_by_path_and_slug() {
663 let doc = Document::parse("# Deploy\n## Roll Back!\ntext\n");
664 assert_eq!(doc.section(["deploy", "roll back!"]).unwrap().heading(), "Roll Back!");
665 assert_eq!(doc.find("roll-back").unwrap().body().trim(), "text");
666 assert!(doc.section(["Deploy", "Nope"]).is_none());
667 }
668
669 #[test]
670 fn duplicate_headings_get_distinct_slugs() {
671 let doc = Document::parse("# Notes\n# Notes\n# Notes\n");
672 let slugs: Vec<_> = doc.outline().map(|(_, s)| s.slug().as_str().to_owned()).collect();
673 assert_eq!(slugs, ["notes", "notes-1", "notes-2"]);
674 }
675
676 #[test]
677 fn skipped_levels_still_nest() {
678 let doc = Document::parse("# A\n### Deep\n## B\n");
679 let a = &doc.sections()[0];
680 assert_eq!(a.children().len(), 2);
681 assert_eq!(a.children()[0].heading(), "Deep");
682 assert_eq!(a.children()[1].heading(), "B");
683 }
684
685 #[test]
686 fn links_are_classified() {
687 let doc = Document::parse(
688 "# Refs\n\nSee [the runbook](references/runbook.md) and \
689 [home](https://example.com) and [top](#refs).\n\n\
690 ```\n[not a link](nope.md)\n```\n\nInline `[skip](me.md)` code.\n",
691 );
692 let links = doc.links();
693 assert_eq!(links.len(), 3);
694 assert_eq!(links[0].target, LinkTarget::InPackage("references/runbook.md".into()));
695 assert_eq!(links[0].text, "the runbook");
696 assert_eq!(links[1].target, LinkTarget::External("https://example.com".into()));
697 assert_eq!(links[2].target, LinkTarget::Fragment("refs".into()));
698 }
699
700 #[test]
701 fn documents_without_headings_are_all_preamble() {
702 let doc = Document::parse("just prose\n");
703 assert!(doc.is_flat());
704 assert_eq!(doc.preamble(), "just prose\n");
705 assert_eq!(doc.outline().count(), 0);
706 }
707
708 #[test]
709 fn heading_level_is_bounded() {
710 assert_eq!(HeadingLevel::new(0), None);
711 assert_eq!(HeadingLevel::new(7), None);
712 assert_eq!(HeadingLevel::new(6).map(HeadingLevel::get), Some(6));
713 let doc = Document::parse("####### nope\n");
715 assert!(doc.is_flat());
716 }
717}