highlight_pulldown/lib.rs
1// Copyright (C) 2023 Enrico Guiraud
2//
3// This file is part of highlight-pulldown.
4//
5// highlight-pulldown is free software: you can redistribute it and/or modify
6// it under the terms of the GNU General Public License as published by
7// the Free Software Foundation, either version 3 of the License, or
8// (at your option) any later version.
9//
10// highlight-pulldown is distributed in the hope that it will be useful,
11// but WITHOUT ANY WARRANTY; without even the implied warranty of
12// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13// GNU General Public License for more details.
14//
15// You should have received a copy of the GNU General Public License
16// along with highlight-pulldown. If not, see <http://www.gnu.org/licenses/>.
17
18//! # Highlight Pulldown Code
19//!
20//! A small library crate to apply syntax highlighting to markdown parsed with [pulldown-cmark](https://crates.io/crates/pulldown-cmark).
21//!
22//! The implementation is based on the discussion at [pulldown-cmark#167](https://github.com/raphlinus/pulldown-cmark/issues/167).
23//!
24//! ## Usage
25//!
26//! The crate exposes a single function, `highlight`.
27//! It takes an iterator over pulldown-cmark events and returns a corresponding `Vec<pulldown_cmark::Event>` where
28//! code blocks have been substituted by HTML blocks containing highlighted code.
29//!
30//! ```rust
31//! use highlight_pulldown::highlight_with_theme;
32//!
33//! let markdown = r#"
34//! ```rust
35//! enum Hello {
36//! World,
37//! SyntaxHighlighting,
38//! }
39//! ```"#;
40//! let events = pulldown_cmark::Parser::new(markdown);
41//!
42//! // apply a syntax highlighting pass to the pulldown_cmark events
43//! let events = highlight_with_theme(events, "base16-ocean.dark").unwrap();
44//!
45//! // emit HTML or further process the events as usual
46//! let mut html = String::new();
47//! pulldown_cmark::html::push_html(&mut html, events.into_iter());
48//! ```
49//!
50//! For better efficiency, instead of invoking `highlight` or `highlight_with_theme` in a hot
51//! loop consider creating a PulldownHighlighter object once and use it many times.
52//!
53//! ## Contributing
54//!
55//! If you happen to use this package, any feedback is more than welcome.
56//!
57//! Contributions in the form of issues or patches via the GitLab repo are even more appreciated.
58
59use pulldown_cmark::{CodeBlockKind, CowStr, Event, Tag};
60use syntect::highlighting::ThemeSet;
61use syntect::html::highlighted_html_for_string;
62use syntect::parsing::SyntaxSet;
63use thiserror::Error;
64
65#[derive(Error, Debug)]
66pub enum Error {
67 #[error("theme '{0}' is not avaiable")]
68 InvalidTheme(String),
69 #[error("error highlighting code")]
70 HighlightError(#[from] syntect::Error),
71}
72
73pub struct PulldownHighlighter {
74 syntaxset: SyntaxSet,
75 themeset: ThemeSet,
76 theme: String,
77}
78
79/// A highlighter that can be instantiated once and used many times for better performance.
80impl PulldownHighlighter {
81 pub fn new(theme: &str) -> Result<PulldownHighlighter, Error> {
82 let syntaxset = SyntaxSet::load_defaults_newlines();
83 let themeset = ThemeSet::load_defaults();
84
85 // check that the theme exists
86 themeset
87 .themes
88 .get(theme)
89 .ok_or(Error::InvalidTheme(theme.to_string()))?;
90
91 Ok(PulldownHighlighter {
92 syntaxset,
93 themeset,
94 theme: theme.to_string(),
95 })
96 }
97
98 /// Apply syntax highlighting to pulldown-cmark events using this instance's theme.
99 ///
100 /// Take an iterator over pulldown-cmark's events, and (on success) return a new iterator
101 /// where code blocks have been turned into HTML text blocks with syntax highlighting.
102 ///
103 /// Implementation based on <https://github.com/raphlinus/pulldown-cmark/issues/167#issuecomment-448491422>.
104 pub fn highlight<'a, It>(&self, events: It) -> Result<Vec<Event<'a>>, Error>
105 where
106 It: Iterator<Item = Event<'a>>,
107 {
108 let mut in_code_block = false;
109
110 let mut syntax = self.syntaxset.find_syntax_plain_text();
111
112 let theme = self
113 .themeset
114 .themes
115 .get(&self.theme)
116 .ok_or(Error::InvalidTheme(self.theme.clone()))?;
117
118 let mut to_highlight = String::new();
119 let mut out_events = Vec::new();
120
121 for event in events {
122 match event {
123 Event::Start(Tag::CodeBlock(kind)) => {
124 match kind {
125 CodeBlockKind::Fenced(lang) => {
126 syntax = self.syntaxset.find_syntax_by_token(&lang).unwrap_or(syntax)
127 }
128 CodeBlockKind::Indented => {}
129 }
130 in_code_block = true;
131 }
132 Event::End(Tag::CodeBlock(_)) => {
133 if !in_code_block {
134 panic!("this should never happen");
135 }
136 let html =
137 highlighted_html_for_string(&to_highlight, &self.syntaxset, syntax, theme)?;
138
139 to_highlight.clear();
140 in_code_block = false;
141 out_events.push(Event::Html(CowStr::from(html)));
142 }
143 Event::Text(t) => {
144 if in_code_block {
145 to_highlight.push_str(&t);
146 } else {
147 out_events.push(Event::Text(t));
148 }
149 }
150 e => {
151 out_events.push(e);
152 }
153 }
154 }
155
156 Ok(out_events)
157 }
158}
159
160/// Apply syntax highlighting to pulldown-cmark events using the specified theme.
161///
162/// Take an iterator over pulldown-cmark's events, and (on success) return a new iterator
163/// where code blocks have been turned into HTML text blocks with syntax highlighting.
164///
165/// It might be time-consuming to call this method in a hot loop: in that situation you
166/// might want to use a PulldownHighlighter object instead.
167pub fn highlight_with_theme<'a, It>(events: It, theme: &str) -> Result<Vec<Event<'a>>, Error>
168where
169 It: Iterator<Item = Event<'a>>,
170{
171 let highlighter = PulldownHighlighter::new(theme)?;
172 highlighter.highlight(events)
173}
174
175/// Apply syntax highlighting to pulldown-cmark events using the default theme.
176///
177/// Take an iterator over pulldown-cmark's events, and (on success) return a new iterator
178/// where code blocks have been turned into HTML text blocks with syntax highlighting.
179///
180/// It might be time-consuming to call this method in a hot loop: in that situation you
181/// might want to use a PulldownHighlighter object instead.
182pub fn highlight<'a, It>(events: It) -> Result<Vec<Event<'a>>, Error>
183where
184 It: Iterator<Item = Event<'a>>,
185{
186 highlight_with_theme(events, "base16-ocean.dark")
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192
193 #[test]
194 fn without_theme() {
195 let markdown = r#"
196 ```python
197 print("foo", 42)
198 ```
199 "#;
200
201 let events = pulldown_cmark::Parser::new(markdown);
202
203 // The themes available are the same as in syntect:
204 // - base16-ocean.dark,base16-eighties.dark,base16-mocha.dark,base16-ocean.light
205 // - InspiredGitHub
206 // - Solarized (dark) and Solarized (light)
207 // See also https://docs.rs/syntect/latest/syntect/highlighting/struct.ThemeSet.html#method.load_defaults .
208 let events = highlight(events).unwrap();
209
210 let mut html = String::new();
211 pulldown_cmark::html::push_html(&mut html, events.into_iter());
212
213 let expected = r#"<pre style="background-color:#2b303b;">
214<span style="color:#c0c5ce;"> ```python
215</span><span style="color:#c0c5ce;"> print("foo", 42)
216</span><span style="color:#c0c5ce;"> ```
217</span></pre>
218"#;
219 assert_eq!(html, expected);
220 }
221
222 #[test]
223 fn with_theme() {
224 let markdown = r#"```python
225print("foo", 42)
226```
227"#;
228
229 let events = pulldown_cmark::Parser::new(markdown);
230 let events = highlight_with_theme(events, "Solarized (dark)").unwrap();
231 let mut html = String::new();
232 pulldown_cmark::html::push_html(&mut html, events.into_iter());
233
234 let expected = r#"<pre style="background-color:#002b36;">
235<span style="color:#859900;">print</span><span style="color:#657b83;">(</span><span style="color:#839496;">"</span><span style="color:#2aa198;">foo</span><span style="color:#839496;">", </span><span style="color:#6c71c4;">42</span><span style="color:#657b83;">)
236</span></pre>
237"#;
238
239 assert_eq!(html, expected);
240 }
241}