Skip to main content

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