pulldown_html_ext/html/
syntect.rs

1use crate::html::{config, HtmlError};
2use crate::html::{HtmlConfig, HtmlState, HtmlWriter, HtmlWriterBase};
3use crate::{html_writer, HtmlRenderer};
4use lazy_static::lazy_static;
5use pulldown_cmark_escape::{escape_html_body_text, StrWrite};
6use serde::{Deserialize, Deserializer};
7use syntect::highlighting::{Theme, ThemeSet};
8use syntect::html::{ClassStyle, ClassedHTMLGenerator};
9use syntect::parsing::SyntaxSet;
10use syntect::util::LinesWithEndings;
11
12lazy_static! {
13    static ref SYNTAX_SET: SyntaxSet = SyntaxSet::load_defaults_newlines();
14    static ref THEME_SET: ThemeSet = ThemeSet::load_defaults();
15}
16
17fn deserialize_class_style<'de, D>(deserializer: D) -> Result<ClassStyle, D::Error>
18where
19    D: Deserializer<'de>,
20{
21    #[derive(Deserialize)]
22    #[serde(rename_all = "snake_case")]
23    enum ClassStyleHelper {
24        Spaced,
25        SpacedPrefix,
26    }
27
28    let style = ClassStyleHelper::deserialize(deserializer)?;
29    Ok(match style {
30        ClassStyleHelper::Spaced => ClassStyle::Spaced,
31        ClassStyleHelper::SpacedPrefix => ClassStyle::SpacedPrefixed { prefix: "" },
32    })
33}
34
35/// Configuration options for syntax highlighting that can be cloned
36#[derive(Clone, Debug, Deserialize)]
37#[serde(rename_all = "snake_case")]
38pub struct SyntectConfigStyle {
39    /// Name of the theme to use (e.g., "base16-ocean.dark")
40    pub theme: String,
41    /// Style of CSS classes to generate
42    #[serde(
43        deserialize_with = "deserialize_class_style",
44        default = "default_class_style"
45    )]
46    pub class_style: ClassStyle,
47    /// Whether to include CSS in the output
48    #[serde(default = "default_inject_css")]
49    pub inject_css: bool,
50}
51
52fn default_class_style() -> ClassStyle {
53    ClassStyle::Spaced
54}
55
56fn default_inject_css() -> bool {
57    true
58}
59
60/// Complete syntax highlighting configuration including non-clonable parts
61#[derive(Debug, Default)]
62pub struct SyntectConfig {
63    /// Style configuration
64    pub style: SyntectConfigStyle,
65    /// Custom syntax set to use (optional) - primarily for testing
66    #[doc(hidden)]
67    pub syntax_set: Option<SyntaxSet>,
68    /// Custom theme set to use (optional) - primarily for testing
69    #[doc(hidden)]
70    pub theme_set: Option<ThemeSet>,
71}
72
73impl Default for SyntectConfigStyle {
74    fn default() -> Self {
75        Self {
76            theme: "base16-ocean.dark".to_string(),
77            class_style: ClassStyle::Spaced,
78            inject_css: true,
79        }
80    }
81}
82
83impl HtmlConfig {
84    /// Create a new configuration with syntect syntax highlighting enabled
85    pub fn with_syntect(syntect_config: SyntectConfig) -> Self {
86        HtmlConfig {
87            syntect: Some(syntect_config.style),
88            ..Default::default()
89        }
90    }
91}
92
93/// Writer that adds syntax highlighting to code blocks
94#[html_writer]
95pub struct SyntectWriter<'a, W: StrWrite>
96where
97    W: 'a,
98{
99    base: HtmlWriterBase<W>,
100    style: SyntectConfigStyle,
101    syntax_set: Option<&'a SyntaxSet>,
102    theme_set: Option<&'a ThemeSet>,
103    current_lang: Option<String>,
104}
105
106impl<'a, W: StrWrite> SyntectWriter<'a, W> {
107    pub fn new(writer: W, config: &'a config::HtmlConfig) -> Self {
108        let style = config.syntect.clone().unwrap_or_default();
109        Self {
110            base: HtmlWriterBase::new(writer, config.clone()),
111            style,
112            syntax_set: None,
113            theme_set: None,
114            current_lang: None,
115        }
116    }
117
118    pub fn with_custom_sets(
119        writer: W,
120        config: &'a config::HtmlConfig,
121        syntax_set: Option<&'a SyntaxSet>,
122        theme_set: Option<&'a ThemeSet>,
123    ) -> Self {
124        let style = config.syntect.clone().unwrap_or_default();
125        Self {
126            base: HtmlWriterBase::new(writer, config.clone()),
127            style,
128            syntax_set,
129            theme_set,
130            current_lang: None,
131        }
132    }
133
134    fn highlight_code(&self, code: &str, lang: Option<&str>) -> String {
135        let syntax_set = self.syntax_set.unwrap_or(&SYNTAX_SET);
136
137        let syntax = match lang {
138            Some(lang) => syntax_set
139                .find_syntax_by_token(lang)
140                .or_else(|| syntax_set.find_syntax_by_extension(lang)),
141            None => None,
142        }
143        .unwrap_or_else(|| syntax_set.find_syntax_plain_text());
144
145        let mut html_generator =
146            ClassedHTMLGenerator::new_with_class_style(syntax, syntax_set, self.style.class_style);
147
148        for line in LinesWithEndings::from(code) {
149            let _ = html_generator.parse_html_for_line_which_includes_newline(line);
150        }
151
152        html_generator.finalize()
153    }
154
155    fn get_theme(&self) -> Result<&Theme, String> {
156        let theme_set = self.theme_set.unwrap_or(&THEME_SET);
157        theme_set
158            .themes
159            .get(&self.style.theme)
160            .ok_or_else(|| format!("Theme '{}' not found", self.style.theme))
161    }
162
163    pub fn get_theme_css(&self) -> Result<String, String> {
164        let theme = self.get_theme()?;
165        syntect::html::css_for_theme_with_class_style(theme, self.style.class_style)
166            .map_err(|e| e.to_string())
167    }
168}
169
170impl<'a, W: StrWrite> SyntectWriter<'a, W> {
171    fn start_code_block(&mut self, kind: pulldown_cmark::CodeBlockKind) -> Result<(), HtmlError> {
172        self.current_lang = match kind {
173            pulldown_cmark::CodeBlockKind::Fenced(ref info) => {
174                if info.is_empty() {
175                    None
176                } else {
177                    Some(info.to_string())
178                }
179            }
180            _ => None,
181        };
182
183        self.write_str("<pre")?;
184        self.write_attributes("pre")?;
185        self.write_str("><code")?;
186
187        if let Some(ref lang) = self.current_lang {
188            self.write_str(&format!(" class=\"language-{}\"", lang))?;
189        }
190
191        self.write_attributes("code")?;
192        self.write_str(">")?;
193
194        self.get_state().currently_in_code_block = true;
195        Ok(())
196    }
197
198    fn text(&mut self, text: &str) -> Result<(), HtmlError> {
199        if self.get_state().currently_in_code_block {
200            let highlighted = self.highlight_code(text, self.current_lang.as_deref());
201            self.write_str(&highlighted)
202        } else {
203            if self.get_config().html.escape_html {
204                escape_html_body_text(self.get_writer(), text)
205                    .map_err(|_| HtmlError::Write(std::fmt::Error))?;
206            } else {
207                self.write_str(text)?;
208            }
209            Ok(())
210        }
211    }
212
213    fn end_code_block(&mut self) -> Result<(), HtmlError> {
214        self.write_str("</code></pre>")?;
215        self.current_lang = None;
216        self.get_state().currently_in_code_block = false;
217        Ok(())
218    }
219}
220
221/// Convenience function to render Markdown with syntax highlighting
222pub fn push_html_with_highlighting(
223    markdown: &str,
224    config: &HtmlConfig,
225) -> Result<String, HtmlError> {
226    use pulldown_cmark::Parser;
227    use pulldown_cmark_escape::FmtWriter;
228
229    let mut output = String::new();
230    let writer = SyntectWriter::new(FmtWriter(&mut output), config);
231    let mut renderer: HtmlRenderer<FmtWriter<&mut String>, SyntectWriter<FmtWriter<&mut String>>> =
232        HtmlRenderer::new(writer);
233
234    let parser = Parser::new(markdown);
235    renderer.run(parser)?;
236
237    // Add CSS if configured
238    if let Some(ref style) = config.syntect {
239        if style.inject_css {
240            match renderer.writer.get_theme_css() {
241                Ok(css) => return Ok(format!("<style>{}</style>\n{}", css, output)),
242                Err(e) => eprintln!("Failed to generate syntax highlighting CSS: {}", e),
243            }
244        }
245    }
246
247    Ok(output)
248}