cobalt/
syntax_highlight.rs

1use std::io::Write;
2
3use crate::error;
4use itertools::Itertools;
5use liquid_core::Language;
6use liquid_core::TagBlock;
7use liquid_core::TagTokenIter;
8use liquid_core::ValueView;
9use liquid_core::error::ResultLiquidReplaceExt;
10use liquid_core::parser::TryMatchToken;
11use liquid_core::{Renderable, Runtime};
12use pulldown_cmark as cmark;
13use pulldown_cmark::Event::{self, End, Html, Start, Text};
14
15#[cfg(not(feature = "syntax-highlight"))]
16pub use engarde::Raw as SyntaxHighlight;
17#[cfg(feature = "syntax-highlight")]
18pub use engarde::Syntax as SyntaxHighlight;
19
20#[derive(Clone, Debug)]
21struct CodeBlock {
22    syntax: std::sync::Arc<SyntaxHighlight>,
23    lang: Option<liquid::model::KString>,
24    code: String,
25    theme: Option<liquid::model::KString>,
26}
27
28impl Renderable for CodeBlock {
29    fn render_to(
30        &self,
31        writer: &mut dyn Write,
32        _context: &dyn Runtime,
33    ) -> Result<(), liquid_core::Error> {
34        write!(
35            writer,
36            "{}",
37            self.syntax
38                .format(&self.code, self.lang.as_deref(), self.theme.as_deref())
39        )
40        .replace("Failed to render")?;
41
42        Ok(())
43    }
44}
45
46#[derive(Clone, Debug)]
47pub(crate) struct CodeBlockParser {
48    syntax: std::sync::Arc<SyntaxHighlight>,
49    syntax_theme: Option<liquid::model::KString>,
50}
51
52impl CodeBlockParser {
53    pub(crate) fn new(
54        syntax: std::sync::Arc<SyntaxHighlight>,
55        theme: Option<liquid::model::KString>,
56    ) -> error::Result<Self> {
57        verify_theme(&syntax, theme.as_deref())?;
58        Ok(Self {
59            syntax,
60            syntax_theme: theme,
61        })
62    }
63}
64
65fn verify_theme(syntax: &SyntaxHighlight, theme: Option<&str>) -> error::Result<()> {
66    if let Some(theme) = &theme {
67        match has_syntax_theme(syntax, theme) {
68            Ok(true) => {}
69            Ok(false) => anyhow::bail!("Syntax theme '{}' is unsupported", theme),
70            Err(err) => {
71                log::warn!("Syntax theme named '{theme}' ignored. Reason: {err}");
72            }
73        };
74    }
75    Ok(())
76}
77
78#[cfg(feature = "syntax-highlight")]
79fn has_syntax_theme(syntax: &SyntaxHighlight, name: &str) -> error::Result<bool> {
80    Ok(syntax.has_theme(name))
81}
82
83#[cfg(not(feature = "syntax-highlight"))]
84fn has_syntax_theme(syntax: &SyntaxHighlight, name: &str) -> error::Result<bool> {
85    anyhow::bail!("Themes are unsupported in this build.");
86}
87
88impl liquid_core::BlockReflection for CodeBlockParser {
89    fn start_tag(&self) -> &'static str {
90        "highlight"
91    }
92
93    fn end_tag(&self) -> &'static str {
94        "endhighlight"
95    }
96
97    fn description(&self) -> &'static str {
98        "Syntax highlight code using HTML"
99    }
100}
101
102impl liquid_core::ParseBlock for CodeBlockParser {
103    fn reflection(&self) -> &dyn liquid_core::BlockReflection {
104        self
105    }
106
107    fn parse(
108        &self,
109        mut arguments: TagTokenIter<'_>,
110        mut tokens: TagBlock<'_, '_>,
111        _options: &Language,
112    ) -> Result<Box<dyn Renderable>, liquid_core::Error> {
113        let lang = arguments
114            .expect_next("Identifier or literal expected.")
115            .ok()
116            .map(|lang| {
117                // This may accept strange inputs such as `{% include 0 %}` or `{% include filterchain | filter:0 %}`.
118                // Those inputs would fail anyway by there being not a path with those langs so they are not a big concern.
119                match lang.expect_literal() {
120                    // Using `to_str()` on literals ensures `Strings` will have their quotes trimmed.
121                    TryMatchToken::Matches(lang) => lang.to_kstr().into_owned(),
122                    TryMatchToken::Fails(lang) => liquid::model::KString::from_ref(lang.as_str()),
123                }
124            });
125        // no more arguments should be supplied, trying to supply them is an error
126        arguments.expect_nothing()?;
127
128        let mut content = String::new();
129        while let Some(element) = tokens.next()? {
130            content.push_str(element.as_str());
131        }
132        tokens.assert_empty();
133
134        Ok(Box::new(CodeBlock {
135            syntax: self.syntax.clone(),
136            code: content,
137            lang,
138            theme: self.syntax_theme.clone(),
139        }))
140    }
141}
142
143pub(crate) struct DecoratedParser<'a> {
144    parser: cmark::Parser<'a>,
145    syntax: std::sync::Arc<SyntaxHighlight>,
146    theme: Option<&'a str>,
147    lang: Option<String>,
148    code: Option<Vec<pulldown_cmark::CowStr<'a>>>,
149}
150
151impl<'a> DecoratedParser<'a> {
152    pub(crate) fn new(
153        parser: cmark::Parser<'a>,
154        syntax: std::sync::Arc<SyntaxHighlight>,
155        theme: Option<&'a str>,
156    ) -> error::Result<Self> {
157        verify_theme(&syntax, theme)?;
158        Ok(DecoratedParser {
159            parser,
160            syntax,
161            theme,
162            lang: None,
163            code: None,
164        })
165    }
166}
167
168impl<'a> Iterator for DecoratedParser<'a> {
169    type Item = Event<'a>;
170
171    fn next(&mut self) -> Option<Event<'a>> {
172        match self.parser.next() {
173            Some(Text(text)) => {
174                if let Some(ref mut code) = self.code {
175                    code.push(text);
176                    Some(Text(pulldown_cmark::CowStr::Borrowed("")))
177                } else {
178                    Some(Text(text))
179                }
180            }
181            Some(Start(cmark::Tag::CodeBlock(info))) => {
182                let tag = match info {
183                    pulldown_cmark::CodeBlockKind::Indented => "",
184                    pulldown_cmark::CodeBlockKind::Fenced(ref tag) => tag.as_ref(),
185                };
186                self.lang = tag.split(' ').map(|s| s.to_owned()).next();
187                self.code = Some(vec![]);
188                Some(Text(pulldown_cmark::CowStr::Borrowed("")))
189            }
190            Some(End(cmark::TagEnd::CodeBlock)) => {
191                let html = if let Some(code) = self.code.as_deref() {
192                    let code = code.iter().join("\n");
193                    self.syntax.format(&code, self.lang.as_deref(), self.theme)
194                } else {
195                    self.syntax.format("", self.lang.as_deref(), self.theme)
196                };
197                // reset highlighter
198                self.lang = None;
199                self.code = None;
200                // close the code block
201                Some(Html(pulldown_cmark::CowStr::Boxed(html.into_boxed_str())))
202            }
203            item => item,
204        }
205    }
206}
207
208pub(crate) fn decorate_markdown<'a>(
209    parser: cmark::Parser<'a>,
210    syntax: std::sync::Arc<SyntaxHighlight>,
211    theme_name: Option<&'a str>,
212) -> error::Result<DecoratedParser<'a>> {
213    DecoratedParser::new(parser, syntax, theme_name)
214}
215
216#[cfg(test)]
217#[cfg(feature = "syntax-highlight")]
218mod test_syntsx {
219    use super::*;
220
221    use snapbox::assert_data_eq;
222    use snapbox::prelude::*;
223    use snapbox::str;
224
225    const CODE_BLOCK: &str = "mod test {
226        fn hello(arg: int) -> bool {
227            \
228                                      true
229        }
230    }
231    ";
232
233    #[test]
234    fn highlight_block_renders_rust() {
235        let syntax = std::sync::Arc::new(SyntaxHighlight::new());
236        let highlight: Box<dyn liquid_core::ParseBlock> =
237            Box::new(CodeBlockParser::new(syntax, Some("base16-ocean.dark".into())).unwrap());
238        let parser = liquid::ParserBuilder::new()
239            .block(highlight)
240            .build()
241            .unwrap();
242        let template = parser
243            .parse(&format!(
244                "{{% highlight rust %}}{CODE_BLOCK}{{% endhighlight %}}"
245            ))
246            .unwrap();
247        let output = template.render(&liquid::Object::new());
248        let expected = str![[r#"
249<pre style="background-color:#2b303b;">
250<code><span style="color:#b48ead;">mod </span><span style="color:#c0c5ce;">test {
251</span><span style="color:#c0c5ce;">        </span><span style="color:#b48ead;">fn </span><span style="color:#8fa1b3;">hello</span><span style="color:#c0c5ce;">(</span><span style="color:#bf616a;">arg</span><span style="color:#c0c5ce;">: int) -&gt; </span><span style="color:#b48ead;">bool </span><span style="color:#c0c5ce;">{
252</span><span style="color:#c0c5ce;">            </span><span style="color:#d08770;">true
253</span><span style="color:#c0c5ce;">        }
254</span><span style="color:#c0c5ce;">    }
255</span><span style="color:#c0c5ce;">    </span></code></pre>
256
257"#]];
258
259        assert_data_eq!(output.unwrap(), expected.raw());
260    }
261
262    #[test]
263    fn markdown_renders_rust() {
264        let html = format!(
265            "```rust
266{CODE_BLOCK}
267```"
268        );
269
270        let mut buf = String::new();
271        let parser = cmark::Parser::new(&html);
272        let syntax = std::sync::Arc::new(SyntaxHighlight::new());
273        cmark::html::push_html(
274            &mut buf,
275            decorate_markdown(parser, syntax, Some("base16-ocean.dark")).unwrap(),
276        );
277        let expected = str![[r#"
278<pre style="background-color:#2b303b;">
279<code><span style="color:#b48ead;">mod </span><span style="color:#c0c5ce;">test {
280</span><span style="color:#c0c5ce;">        </span><span style="color:#b48ead;">fn </span><span style="color:#8fa1b3;">hello</span><span style="color:#c0c5ce;">(</span><span style="color:#bf616a;">arg</span><span style="color:#c0c5ce;">: int) -&gt; </span><span style="color:#b48ead;">bool </span><span style="color:#c0c5ce;">{
281</span><span style="color:#c0c5ce;">            </span><span style="color:#d08770;">true
282</span><span style="color:#c0c5ce;">        }
283</span><span style="color:#c0c5ce;">    }
284</span><span style="color:#c0c5ce;">    
285</span></code></pre>
286
287"#]];
288
289        assert_data_eq!(&buf, expected.raw());
290    }
291}
292
293#[cfg(test)]
294#[cfg(not(feature = "syntax-highlight"))]
295mod test_raw {
296    use super::*;
297
298    use snapbox::assert_data_eq;
299    use snapbox::prelude::*;
300    use snapbox::str;
301
302    const CODE_BLOCK: &str = "mod test {
303        fn hello(arg: int) -> bool {
304            \
305                                      true
306        }
307    }
308";
309
310    #[test]
311    fn codeblock_renders_rust() {
312        let syntax = std::sync::Arc::new(SyntaxHighlight::new());
313        let highlight: Box<dyn liquid_core::ParseBlock> =
314            Box::new(CodeBlockParser::new(syntax, Some("base16-ocean.dark".into())).unwrap());
315        let parser = liquid::ParserBuilder::new()
316            .block(highlight)
317            .build()
318            .unwrap();
319        let template = parser
320            .parse(&format!(
321                "{{% highlight rust %}}{}{{% endhighlight %}}",
322                CODE_BLOCK
323            ))
324            .unwrap();
325        let output = template.render(&liquid::Object::new());
326        let expected = str![[r#"
327<pre><code class="language-rust">mod test {
328        fn hello(arg: int) -&gt; bool {
329            true
330        }
331    }
332</code></pre>
333
334"#]];
335
336        assert_data_eq!(output.unwrap(), expected.raw());
337    }
338
339    #[test]
340    fn decorate_markdown_renders_rust() {
341        let html = format!(
342            "```rust
343{}
344```",
345            CODE_BLOCK
346        );
347
348        let mut buf = String::new();
349        let parser = cmark::Parser::new(&html);
350        let syntax = std::sync::Arc::new(SyntaxHighlight::new());
351        cmark::html::push_html(
352            &mut buf,
353            decorate_markdown(parser, syntax, Some("base16-ocean.dark")).unwrap(),
354        );
355        let expected = str![[r#"
356<pre><code class="language-rust">mod test {
357        fn hello(arg: int) -&gt; bool {
358            true
359        }
360    }
361
362</code></pre>
363
364"#]];
365
366        assert_data_eq!(&buf, expected.raw());
367    }
368}