typstify_parser/
syntax.rs

1//! Syntax highlighting for code blocks.
2
3use syntect::{highlighting::ThemeSet, html::highlighted_html_for_string, parsing::SyntaxSet};
4use thiserror::Error;
5
6/// Syntax highlighting errors.
7#[derive(Debug, Error)]
8pub enum SyntaxError {
9    /// Failed to highlight code.
10    #[error("syntax highlighting failed: {0}")]
11    Highlight(String),
12}
13
14/// Syntax highlighter using syntect.
15#[derive(Debug)]
16pub struct SyntaxHighlighter {
17    syntax_set: SyntaxSet,
18    theme_set: ThemeSet,
19    default_theme: String,
20}
21
22impl Default for SyntaxHighlighter {
23    fn default() -> Self {
24        Self::new("base16-ocean.dark")
25    }
26}
27
28impl SyntaxHighlighter {
29    /// Create a new syntax highlighter with the specified theme.
30    pub fn new(theme: &str) -> Self {
31        Self {
32            syntax_set: SyntaxSet::load_defaults_newlines(),
33            theme_set: ThemeSet::load_defaults(),
34            default_theme: theme.to_string(),
35        }
36    }
37
38    /// Get available theme names.
39    pub fn available_themes(&self) -> Vec<&str> {
40        self.theme_set.themes.keys().map(|s| s.as_str()).collect()
41    }
42
43    /// Highlight code with the given language.
44    ///
45    /// If the language is not recognized, returns the code wrapped in a `<pre><code>` block.
46    pub fn highlight(&self, code: &str, lang: Option<&str>) -> String {
47        let syntax = lang
48            .and_then(|l| self.syntax_set.find_syntax_by_token(l))
49            .or_else(|| self.syntax_set.find_syntax_by_extension("txt"));
50
51        let theme = self
52            .theme_set
53            .themes
54            .get(&self.default_theme)
55            .or_else(|| self.theme_set.themes.values().next());
56
57        match (syntax, theme) {
58            (Some(syntax), Some(theme)) => {
59                match highlighted_html_for_string(code, &self.syntax_set, syntax, theme) {
60                    Ok(html) => html,
61                    Err(_) => self.fallback_highlight(code, lang),
62                }
63            }
64            _ => self.fallback_highlight(code, lang),
65        }
66    }
67
68    /// Fallback highlighting when syntect fails.
69    fn fallback_highlight(&self, code: &str, lang: Option<&str>) -> String {
70        let escaped = html_escape(code);
71        let lang_class = lang
72            .map(|l| format!(" class=\"language-{l}\""))
73            .unwrap_or_default();
74        format!("<pre><code{lang_class}>{escaped}</code></pre>")
75    }
76
77    /// Set the default theme.
78    pub fn set_theme(&mut self, theme: &str) {
79        if self.theme_set.themes.contains_key(theme) {
80            self.default_theme = theme.to_string();
81        }
82    }
83}
84
85/// Escape HTML special characters.
86fn html_escape(s: &str) -> String {
87    s.replace('&', "&amp;")
88        .replace('<', "&lt;")
89        .replace('>', "&gt;")
90        .replace('"', "&quot;")
91        .replace('\'', "&#x27;")
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97
98    #[test]
99    fn test_highlight_rust() {
100        let highlighter = SyntaxHighlighter::default();
101        let code = "fn main() {\n    println!(\"Hello\");\n}";
102        let html = highlighter.highlight(code, Some("rust"));
103
104        assert!(html.contains("<pre"));
105        assert!(html.contains("fn"));
106    }
107
108    #[test]
109    fn test_highlight_unknown_language() {
110        let highlighter = SyntaxHighlighter::default();
111        let code = "some code";
112        let html = highlighter.highlight(code, Some("unknown_lang_xyz"));
113
114        // Should fall back gracefully
115        assert!(html.contains("some code"));
116    }
117
118    #[test]
119    fn test_highlight_no_language() {
120        let highlighter = SyntaxHighlighter::default();
121        let code = "plain text";
122        let html = highlighter.highlight(code, None);
123
124        assert!(html.contains("plain text"));
125    }
126
127    #[test]
128    fn test_html_escape() {
129        assert_eq!(html_escape("<script>"), "&lt;script&gt;");
130        assert_eq!(html_escape("a & b"), "a &amp; b");
131    }
132
133    #[test]
134    fn test_available_themes() {
135        let highlighter = SyntaxHighlighter::default();
136        let themes = highlighter.available_themes();
137
138        assert!(!themes.is_empty());
139        assert!(themes.contains(&"base16-ocean.dark"));
140    }
141}