Skip to main content

mdx_gen/
highlight.rs

1//! Syntax highlighting adapter for comrak.
2//!
3//! Implements comrak's `SyntaxHighlighterAdapter` trait using
4//! syntect's class-based generator. Output uses CSS class names
5//! (`<span class="…">`) rather than inline `style="…"` attributes,
6//! which means callers must ship a stylesheet — see [`theme_css`]
7//! for generating one from any built-in theme.
8
9use comrak::adapters::SyntaxHighlighterAdapter;
10use std::borrow::Cow;
11use std::collections::HashMap;
12use std::fmt::{self, Write};
13use std::sync::LazyLock;
14use syntect::highlighting::ThemeSet;
15use syntect::html::{
16    css_for_theme_with_class_style, ClassStyle, ClassedHTMLGenerator,
17};
18use syntect::parsing::SyntaxSet;
19use syntect::util::LinesWithEndings;
20
21/// Cached `SyntaxSet` to avoid reloading on every function call.
22static SYNTAX_SET: LazyLock<SyntaxSet> =
23    LazyLock::new(SyntaxSet::load_defaults_newlines);
24/// Cached `ThemeSet` to avoid reloading on every function call.
25static THEME_SET: LazyLock<ThemeSet> =
26    LazyLock::new(ThemeSet::load_defaults);
27
28/// Default theme used when none is specified.
29pub const DEFAULT_THEME: &str = "base16-ocean.dark";
30
31/// Class style used by every generator we construct.
32///
33/// `ClassStyle::Spaced` emits ` class="foo bar"` form, which works
34/// with the CSS produced by [`theme_css`].
35const CLASS_STYLE: ClassStyle = ClassStyle::Spaced;
36
37/// A syntect-backed adapter for comrak's rendering plugin system.
38///
39/// Performs syntax highlighting during HTML rendering using
40/// class-based output. The adapter does not emit `<pre>` / `<code>`
41/// itself — comrak's renderer handles those tags via
42/// [`SyntaxHighlighterAdapter::write_pre_tag`] and
43/// [`SyntaxHighlighterAdapter::write_code_tag`].
44pub struct SyntectAdapter {
45    theme_name: String,
46}
47
48impl SyntectAdapter {
49    /// Creates a new adapter, optionally with a named theme.
50    ///
51    /// The theme name is retained so callers can recover it via
52    /// [`SyntectAdapter::theme_name`] and pass it to [`theme_css`]
53    /// when generating a stylesheet for the rendered output. Falls
54    /// back to [`DEFAULT_THEME`] when the requested theme is not in
55    /// syntect's built-in set.
56    pub fn new(theme: Option<&str>) -> Self {
57        let theme_name = theme
58            .filter(|t| THEME_SET.themes.contains_key(*t))
59            .unwrap_or(DEFAULT_THEME)
60            .to_owned();
61        Self { theme_name }
62    }
63
64    /// Returns the resolved theme name (after fallback).
65    pub fn theme_name(&self) -> &str {
66        &self.theme_name
67    }
68
69    /// Returns the list of available theme names.
70    pub fn available_themes() -> Vec<&'static str> {
71        THEME_SET.themes.keys().map(|s| s.as_str()).collect()
72    }
73}
74
75impl SyntaxHighlighterAdapter for SyntectAdapter {
76    fn write_highlighted(
77        &self,
78        output: &mut dyn Write,
79        lang: Option<&str>,
80        code: &str,
81    ) -> fmt::Result {
82        let syntax = lang
83            .and_then(|l| SYNTAX_SET.find_syntax_by_token(l))
84            .unwrap_or_else(|| SYNTAX_SET.find_syntax_plain_text());
85
86        let mut generator = ClassedHTMLGenerator::new_with_class_style(
87            syntax,
88            &SYNTAX_SET,
89            CLASS_STYLE,
90        );
91
92        for line in LinesWithEndings::from(code) {
93            if generator
94                .parse_html_for_line_which_includes_newline(line)
95                .is_err()
96            {
97                // Highlighter gave up part-way: fall back to plain
98                // escaped text rather than emitting a half-built
99                // span tree.
100                return output
101                    .write_str(&html_escape::encode_text(code));
102            }
103        }
104
105        output.write_str(&generator.finalize())
106    }
107
108    fn write_pre_tag(
109        &self,
110        output: &mut dyn Write,
111        attributes: HashMap<&'static str, Cow<'_, str>>,
112    ) -> fmt::Result {
113        write!(output, "<pre")?;
114        for (attr, value) in &attributes {
115            write!(
116                output,
117                " {}=\"{}\"",
118                attr,
119                html_escape::encode_double_quoted_attribute(value)
120            )?;
121        }
122        write!(output, ">")
123    }
124
125    fn write_code_tag(
126        &self,
127        output: &mut dyn Write,
128        attributes: HashMap<&'static str, Cow<'_, str>>,
129    ) -> fmt::Result {
130        write!(output, "<code")?;
131        for (attr, value) in &attributes {
132            // Skip empty attributes
133            if value.is_empty() {
134                continue;
135            }
136            write!(
137                output,
138                " {}=\"{}\"",
139                attr,
140                html_escape::encode_double_quoted_attribute(value)
141            )?;
142        }
143        write!(output, ">")
144    }
145}
146
147/// Highlights a code string with class-based spans (standalone API).
148///
149/// This is the public entry point for callers who want to highlight
150/// code outside of the markdown pipeline. Output is a sequence of
151/// `<span class="…">` tags — pair it with [`theme_css`] to produce
152/// a stylesheet that renders the colours.
153pub fn apply_syntax_highlighting(
154    code: &str,
155    lang: &str,
156) -> Result<String, crate::error::MarkdownError> {
157    let syntax = SYNTAX_SET
158        .find_syntax_by_token(lang)
159        .unwrap_or_else(|| SYNTAX_SET.find_syntax_plain_text());
160
161    let mut generator = ClassedHTMLGenerator::new_with_class_style(
162        syntax,
163        &SYNTAX_SET,
164        CLASS_STYLE,
165    );
166
167    for line in LinesWithEndings::from(code) {
168        generator
169            .parse_html_for_line_which_includes_newline(line)
170            .map_err(|e| {
171                crate::error::MarkdownError::SyntaxHighlightError(
172                    e.to_string(),
173                )
174            })?;
175    }
176
177    Ok(generator.finalize())
178}
179
180/// Generates a CSS stylesheet for the named built-in theme.
181///
182/// Returns `None` if the theme is not present. The generated CSS
183/// targets the class names emitted by [`apply_syntax_highlighting`]
184/// and the comrak adapter, so callers can either inline the result
185/// in a `<style>` block or write it to a `.css` file.
186pub fn theme_css(theme_name: &str) -> Option<String> {
187    let theme = THEME_SET.themes.get(theme_name)?;
188    css_for_theme_with_class_style(theme, CLASS_STYLE).ok()
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    #[test]
196    fn test_syntect_adapter_emits_class_spans() {
197        let adapter = SyntectAdapter::new(None);
198        let mut output = String::new();
199        adapter
200            .write_highlighted(
201                &mut output,
202                Some("rust"),
203                "fn main() {}",
204            )
205            .unwrap();
206        assert!(
207            output.contains("<span class="),
208            "should contain class-based syntax spans, got: {output}"
209        );
210        assert!(
211            !output.contains(" style=\""),
212            "must not contain inline styles: {output}"
213        );
214    }
215
216    #[test]
217    fn test_syntect_adapter_unknown_lang_fallback() {
218        let adapter = SyntectAdapter::new(None);
219        let mut output = String::new();
220        adapter
221            .write_highlighted(
222                &mut output,
223                Some("nonexistent-lang-xyz"),
224                "hello world",
225            )
226            .unwrap();
227        // Should not panic, should produce some output
228        assert!(!output.is_empty());
229    }
230
231    #[test]
232    fn test_syntect_adapter_invalid_theme_falls_back() {
233        let adapter = SyntectAdapter::new(Some("no-such-theme"));
234        assert_eq!(adapter.theme_name(), DEFAULT_THEME);
235    }
236
237    #[test]
238    fn test_available_themes_not_empty() {
239        let themes = SyntectAdapter::available_themes();
240        assert!(!themes.is_empty());
241        assert!(themes.contains(&DEFAULT_THEME));
242    }
243
244    #[test]
245    fn test_standalone_highlighting_emits_classes() {
246        let html =
247            apply_syntax_highlighting("fn main() {}", "rust").unwrap();
248        assert!(html.contains("<span class="));
249        assert!(!html.contains(" style=\""));
250    }
251
252    #[test]
253    fn test_write_pre_tag_with_attributes() {
254        let adapter = SyntectAdapter::new(None);
255        let mut output = String::new();
256        let mut attrs = HashMap::new();
257        attrs.insert("class", Cow::Borrowed("highlight"));
258        attrs.insert("data-lang", Cow::Borrowed("rust"));
259        adapter.write_pre_tag(&mut output, attrs).unwrap();
260        assert!(output.starts_with("<pre"));
261        assert!(output.ends_with('>'));
262        assert!(output.contains("class=\"highlight\""));
263        assert!(output.contains("data-lang=\"rust\""));
264    }
265
266    #[test]
267    fn test_write_pre_tag_no_attributes() {
268        let adapter = SyntectAdapter::new(None);
269        let mut output = String::new();
270        adapter.write_pre_tag(&mut output, HashMap::new()).unwrap();
271        assert_eq!(output, "<pre>");
272    }
273
274    #[test]
275    fn test_write_code_tag_with_attributes() {
276        let adapter = SyntectAdapter::new(None);
277        let mut output = String::new();
278        let mut attrs = HashMap::new();
279        attrs.insert("class", Cow::Borrowed("language-rust"));
280        adapter.write_code_tag(&mut output, attrs).unwrap();
281        assert!(output.starts_with("<code"));
282        assert!(output.ends_with('>'));
283        assert!(output.contains("class=\"language-rust\""));
284    }
285
286    #[test]
287    fn test_write_code_tag_skips_empty_attributes() {
288        let adapter = SyntectAdapter::new(None);
289        let mut output = String::new();
290        let mut attrs = HashMap::new();
291        attrs.insert("class", Cow::Borrowed(""));
292        attrs.insert("id", Cow::Borrowed("my-code"));
293        adapter.write_code_tag(&mut output, attrs).unwrap();
294        // Empty "class" value should be skipped
295        assert!(!output.contains("class"));
296        // Non-empty "id" should be present
297        assert!(output.contains("id=\"my-code\""));
298    }
299
300    #[test]
301    fn test_write_code_tag_no_attributes() {
302        let adapter = SyntectAdapter::new(None);
303        let mut output = String::new();
304        adapter.write_code_tag(&mut output, HashMap::new()).unwrap();
305        assert_eq!(output, "<code>");
306    }
307
308    #[test]
309    fn test_write_pre_tag_escapes_attribute_values() {
310        let adapter = SyntectAdapter::new(None);
311        let mut output = String::new();
312        let mut attrs = HashMap::new();
313        attrs.insert("data-info", Cow::Borrowed("a\"b"));
314        adapter.write_pre_tag(&mut output, attrs).unwrap();
315        // The double quote inside the value should be escaped
316        assert!(!output.contains("a\"b"));
317        assert!(output.contains("data-info="));
318    }
319
320    #[test]
321    fn test_write_highlighted_no_lang() {
322        let adapter = SyntectAdapter::new(None);
323        let mut output = String::new();
324        adapter
325            .write_highlighted(&mut output, None, "plain text")
326            .unwrap();
327        assert!(!output.is_empty());
328    }
329
330    #[test]
331    fn test_standalone_highlighting_unknown_language() {
332        // Unknown language should fall back to plain text, not error
333        let result = apply_syntax_highlighting(
334            "hello world",
335            "nonexistent-language-xyz",
336        );
337        assert!(result.is_ok());
338    }
339
340    #[test]
341    fn test_theme_css_known_theme() {
342        let css = theme_css(DEFAULT_THEME).expect("default theme");
343        assert!(css.contains(".code"));
344    }
345
346    #[test]
347    fn test_theme_css_unknown_theme() {
348        assert!(theme_css("no-such-theme").is_none());
349    }
350
351    /// Minimal `fmt::Write` sink that rejects the first write. Used
352    /// to exercise the `?`-on-write_str error paths in the adapter.
353    struct FailingWrite;
354    impl fmt::Write for FailingWrite {
355        fn write_str(&mut self, _: &str) -> fmt::Result {
356            Err(fmt::Error)
357        }
358    }
359
360    #[test]
361    fn test_write_pre_tag_propagates_write_error() {
362        let adapter = SyntectAdapter::new(None);
363        let err =
364            adapter.write_pre_tag(&mut FailingWrite, HashMap::new());
365        assert!(err.is_err());
366    }
367
368    #[test]
369    fn test_write_pre_tag_propagates_attribute_write_error() {
370        // First write (`<pre`) succeeds into the throwaway String
371        // wrapper below; the attribute write errors on first call.
372        struct FailAfterFirst(usize);
373        impl fmt::Write for FailAfterFirst {
374            fn write_str(&mut self, _: &str) -> fmt::Result {
375                if self.0 == 0 {
376                    self.0 += 1;
377                    Ok(())
378                } else {
379                    Err(fmt::Error)
380                }
381            }
382        }
383
384        let adapter = SyntectAdapter::new(None);
385        let mut attrs = HashMap::new();
386        attrs.insert("class", Cow::Borrowed("demo"));
387        let err = adapter
388            .write_pre_tag(&mut FailAfterFirst(0), attrs)
389            .unwrap_err();
390        let _ = err; // just asserting it errored
391    }
392
393    #[test]
394    fn test_write_code_tag_propagates_write_error() {
395        let adapter = SyntectAdapter::new(None);
396        let err =
397            adapter.write_code_tag(&mut FailingWrite, HashMap::new());
398        assert!(err.is_err());
399    }
400
401    #[test]
402    fn test_write_code_tag_propagates_attribute_write_error() {
403        struct FailAfterFirst(usize);
404        impl fmt::Write for FailAfterFirst {
405            fn write_str(&mut self, _: &str) -> fmt::Result {
406                if self.0 == 0 {
407                    self.0 += 1;
408                    Ok(())
409                } else {
410                    Err(fmt::Error)
411                }
412            }
413        }
414
415        let adapter = SyntectAdapter::new(None);
416        let mut attrs = HashMap::new();
417        attrs.insert("class", Cow::Borrowed("language-rust"));
418        let err = adapter
419            .write_code_tag(&mut FailAfterFirst(0), attrs)
420            .unwrap_err();
421        let _ = err;
422    }
423
424    #[test]
425    fn test_write_highlighted_propagates_write_error() {
426        // FailingWrite rejects immediately, so both the fallback
427        // branch and the normal finalize branch error out.
428        let adapter = SyntectAdapter::new(None);
429        let err = adapter
430            .write_highlighted(
431                &mut FailingWrite,
432                Some("rust"),
433                "fn x() {}",
434            )
435            .unwrap_err();
436        let _ = err;
437    }
438}