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(&quot;foo&quot;, 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;">&quot;</span><span style="color:#2aa198;">foo</span><span style="color:#839496;">&quot;, </span><span style="color:#6c71c4;">42</span><span style="color:#657b83;">)
236</span></pre>
237"#;
238
239        assert_eq!(html, expected);
240    }
241}