typstify_parser/
syntax.rs1use syntect::{highlighting::ThemeSet, html::highlighted_html_for_string, parsing::SyntaxSet};
4use thiserror::Error;
5
6#[derive(Debug, Error)]
8pub enum SyntaxError {
9 #[error("syntax highlighting failed: {0}")]
11 Highlight(String),
12}
13
14#[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 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 pub fn available_themes(&self) -> Vec<&str> {
40 self.theme_set.themes.keys().map(|s| s.as_str()).collect()
41 }
42
43 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 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 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
85fn html_escape(s: &str) -> String {
87 s.replace('&', "&")
88 .replace('<', "<")
89 .replace('>', ">")
90 .replace('"', """)
91 .replace('\'', "'")
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 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>"), "<script>");
130 assert_eq!(html_escape("a & b"), "a & 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}