typstify_parser/
syntax.rs1use syntect::{highlighting::ThemeSet, html::highlighted_html_for_string, parsing::SyntaxSet};
4use thiserror::Error;
5use typstify_core::utils::html_escape;
6
7#[derive(Debug, Error)]
9pub enum SyntaxError {
10 #[error("syntax highlighting failed: {0}")]
12 Highlight(String),
13}
14
15#[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 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 pub fn available_themes(&self) -> Vec<&str> {
41 self.theme_set.themes.keys().map(|s| s.as_str()).collect()
42 }
43
44 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 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 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 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}