Skip to main content

markdown/
source.rs

1//! Colouring markdown *source*.
2//!
3//! The one language this crate can classify without help: it already parses
4//! markdown, and a source view needs colour on the platforms
5//! [`crate::highlight`] cannot reach — tree-sitter is C, and a browser has no
6//! libc to build it against.
7//!
8//! Spans are painted into a per-byte map rather than pushed as they arrive,
9//! because markdown nests — a link inside a heading inside a quote — and the
10//! renderer needs them disjoint and in order. Inner events land last and win.
11
12use std::ops::Range;
13
14use pulldown_cmark::{Event, Options, Parser, Tag};
15use theme::HighlightKind;
16
17/// The fence tags that mean "this is markdown".
18pub const LANGUAGES: [&str; 2] = ["md", "markdown"];
19
20/// Whether a fence tag names markdown.
21pub fn is_markdown(language: &str) -> bool {
22    LANGUAGES.contains(&language)
23}
24
25/// Colour `source` as markdown, in bytes and in document order.
26pub fn spans(source: &str) -> Vec<(Range<usize>, HighlightKind)> {
27    let options =
28        Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TASKLISTS;
29    let mut map: Vec<Option<HighlightKind>> = vec![None; source.len()];
30    let mut paint = |range: Range<usize>, kind: HighlightKind| {
31        for slot in &mut map[range.start.min(source.len())..range.end.min(source.len())] {
32            *slot = Some(kind);
33        }
34    };
35
36    for (event, range) in Parser::new_ext(source, options).into_offset_iter() {
37        match event {
38            Event::Start(Tag::Heading { .. }) => paint(range, HighlightKind::Keyword),
39            Event::Start(Tag::BlockQuote(_)) => paint(range, HighlightKind::Comment),
40            Event::Start(Tag::CodeBlock(_)) | Event::Code(_) => paint(range, HighlightKind::String),
41            Event::Start(Tag::Strong | Tag::Emphasis | Tag::Strikethrough) => {
42                paint(range, HighlightKind::Constant);
43            }
44            // The destination only: a link's label is prose and reads as prose.
45            // An autolink has no `](` and is a destination all through.
46            Event::Start(Tag::Link { .. } | Tag::Image { .. }) => {
47                let at = source[range.clone()]
48                    .rfind("](")
49                    .map_or(range.start, |ix| range.start + ix);
50                paint(at..range.end, HighlightKind::Attribute);
51            }
52            // The marker, not the item: the text of a list is prose too.
53            Event::Start(Tag::Item) => paint(marker(source, range), HighlightKind::Punctuation),
54            Event::Start(Tag::Table(_)) => {
55                for (ix, _) in source[range.clone()].match_indices('|') {
56                    paint(
57                        range.start + ix..range.start + ix + 1,
58                        HighlightKind::Punctuation,
59                    );
60                }
61            }
62            Event::TaskListMarker(_) => paint(range, HighlightKind::Boolean),
63            Event::Rule => paint(range, HighlightKind::Punctuation),
64            Event::Html(_) | Event::InlineHtml(_) => paint(range, HighlightKind::Tag),
65            _ => {}
66        }
67    }
68
69    // Run-length encoded back out, which is what makes the result disjoint and
70    // ordered however deeply the source nested.
71    let mut spans: Vec<(Range<usize>, HighlightKind)> = Vec::new();
72    for (at, kind) in map.into_iter().enumerate() {
73        let Some(kind) = kind else { continue };
74        match spans.last_mut() {
75            Some((range, last)) if *last == kind && range.end == at => range.end = at + 1,
76            _ => spans.push((at..at + 1, kind)),
77        }
78    }
79    spans
80}
81
82/// A list item's marker: the indent, the bullet or number, and the space after
83/// it. Everything the item's own range holds before its text starts.
84fn marker(source: &str, range: Range<usize>) -> Range<usize> {
85    let item = &source[range.clone()];
86    let text = item.trim_start();
87    let start = range.start + (item.len() - text.len());
88    let width = text
89        .find(char::is_whitespace)
90        .map_or(text.len(), |ix| ix + 1);
91    start..start + width
92}