1use crate::error::{Error, Result};
12use crate::types::{Language, SyntaxTheme};
13use regex::Regex;
14use std::sync::{LazyLock, OnceLock};
15use tracing::debug;
16use tree_sitter_highlight::{Highlight, HighlightConfiguration, Highlighter, HtmlRenderer};
17
18static HIGHLIGHT_CONFIGS: OnceLock<std::collections::HashMap<Language, HighlightConfiguration>> =
20 OnceLock::new();
21
22#[cfg(feature = "lang-markdown")]
26static MARKDOWN_INLINE_CONFIG: OnceLock<Option<HighlightConfiguration>> = OnceLock::new();
27
28pub struct SyntaxHighlighter {
30 theme: SyntaxTheme,
32 configs: &'static std::collections::HashMap<Language, HighlightConfiguration>,
34}
35
36impl SyntaxHighlighter {
37 pub fn new() -> Self {
39 Self::with_theme(SyntaxTheme::default())
40 }
41
42 pub fn with_theme(theme: SyntaxTheme) -> Self {
44 let configs = HIGHLIGHT_CONFIGS.get_or_init(Self::init_configs);
45 Self { theme, configs }
46 }
47
48 fn init_configs() -> std::collections::HashMap<Language, HighlightConfiguration> {
50 let mut configs = std::collections::HashMap::new();
51
52 #[cfg(feature = "lang-rust")]
53 if let Ok(mut config) = HighlightConfiguration::new(
54 tree_sitter_rust::LANGUAGE.into(),
55 "rust",
56 tree_sitter_rust::HIGHLIGHTS_QUERY,
57 tree_sitter_rust::INJECTIONS_QUERY,
58 "",
59 ) {
60 config.configure(THEME_HIGHLIGHT_NAMES);
61 configs.insert(Language::Rust, config);
62 }
63
64 #[cfg(feature = "lang-python")]
65 if let Ok(mut config) = HighlightConfiguration::new(
66 tree_sitter_python::LANGUAGE.into(),
67 "python",
68 tree_sitter_python::HIGHLIGHTS_QUERY,
69 "",
70 "",
71 ) {
72 config.configure(THEME_HIGHLIGHT_NAMES);
73 configs.insert(Language::Python, config);
74 }
75
76 #[cfg(feature = "lang-javascript")]
77 if let Ok(mut config) = HighlightConfiguration::new(
78 tree_sitter_javascript::LANGUAGE.into(),
79 "javascript",
80 tree_sitter_javascript::HIGHLIGHT_QUERY,
81 tree_sitter_javascript::INJECTIONS_QUERY,
82 "",
83 ) {
84 config.configure(THEME_HIGHLIGHT_NAMES);
85 configs.insert(Language::JavaScript, config);
86 }
87
88 #[cfg(feature = "lang-typescript")]
89 if let Ok(mut config) = HighlightConfiguration::new(
90 tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
91 "typescript",
92 tree_sitter_typescript::HIGHLIGHTS_QUERY,
93 "",
94 "",
95 ) {
96 config.configure(THEME_HIGHLIGHT_NAMES);
97 configs.insert(Language::TypeScript, config);
98 }
99
100 #[cfg(feature = "lang-json")]
101 if let Ok(mut config) = HighlightConfiguration::new(
102 tree_sitter_json::LANGUAGE.into(),
103 "json",
104 tree_sitter_json::HIGHLIGHTS_QUERY,
105 "",
106 "",
107 ) {
108 config.configure(THEME_HIGHLIGHT_NAMES);
109 configs.insert(Language::Json, config);
110 }
111
112 #[cfg(feature = "lang-toml")]
113 if let Ok(mut config) = HighlightConfiguration::new(
114 tree_sitter_toml_ng::LANGUAGE.into(),
115 "toml",
116 tree_sitter_toml_ng::HIGHLIGHTS_QUERY,
117 "",
118 "",
119 ) {
120 config.configure(THEME_HIGHLIGHT_NAMES);
121 configs.insert(Language::Toml, config);
122 }
123
124 #[cfg(feature = "lang-yaml")]
125 if let Ok(mut config) = HighlightConfiguration::new(
126 tree_sitter_yaml::LANGUAGE.into(),
127 "yaml",
128 tree_sitter_yaml::HIGHLIGHTS_QUERY,
129 "",
130 "",
131 ) {
132 config.configure(THEME_HIGHLIGHT_NAMES);
133 configs.insert(Language::Yaml, config);
134 }
135
136 #[cfg(feature = "lang-html")]
137 if let Ok(mut config) = HighlightConfiguration::new(
138 tree_sitter_html::LANGUAGE.into(),
139 "html",
140 tree_sitter_html::HIGHLIGHTS_QUERY,
141 tree_sitter_html::INJECTIONS_QUERY,
142 "",
143 ) {
144 config.configure(THEME_HIGHLIGHT_NAMES);
145 configs.insert(Language::Html, config);
146 }
147
148 #[cfg(feature = "lang-css")]
149 if let Ok(mut config) = HighlightConfiguration::new(
150 tree_sitter_css::LANGUAGE.into(),
151 "css",
152 tree_sitter_css::HIGHLIGHTS_QUERY,
153 "",
154 "",
155 ) {
156 config.configure(THEME_HIGHLIGHT_NAMES);
157 configs.insert(Language::Css, config);
158 }
159
160 #[cfg(feature = "lang-bash")]
161 if let Ok(mut config) = HighlightConfiguration::new(
162 tree_sitter_bash::LANGUAGE.into(),
163 "bash",
164 tree_sitter_bash::HIGHLIGHT_QUERY,
165 "",
166 "",
167 ) {
168 config.configure(THEME_HIGHLIGHT_NAMES);
169 configs.insert(Language::Bash, config);
170 }
171
172 #[cfg(feature = "lang-markdown")]
173 if let Ok(mut config) = HighlightConfiguration::new(
174 tree_sitter_md::LANGUAGE.into(),
175 "markdown",
176 tree_sitter_md::HIGHLIGHT_QUERY_BLOCK,
177 tree_sitter_md::INJECTION_QUERY_BLOCK,
178 "",
179 ) {
180 config.configure(THEME_HIGHLIGHT_NAMES);
181 configs.insert(Language::Markdown, config);
182 }
183
184 #[cfg(feature = "lang-markdown")]
188 {
189 let _ = MARKDOWN_INLINE_CONFIG.get_or_init(|| {
190 HighlightConfiguration::new(
191 tree_sitter_md::INLINE_LANGUAGE.into(),
192 "markdown-inline",
193 tree_sitter_md::HIGHLIGHT_QUERY_INLINE,
194 tree_sitter_md::INJECTION_QUERY_INLINE,
195 "",
196 )
197 .map(|mut c| {
198 c.configure(THEME_HIGHLIGHT_NAMES);
199 c
200 })
201 .ok()
202 });
203 }
204
205 debug!(
210 "Initialized {} syntax highlight configurations",
211 configs.len()
212 );
213 configs
214 }
215
216 fn injected_config(&self, lang_name: &str) -> Option<&HighlightConfiguration> {
218 #[cfg(feature = "lang-markdown")]
219 if lang_name == "markdown-inline" || lang_name == "markdown_inline" {
220 return MARKDOWN_INLINE_CONFIG.get_or_init(|| None).as_ref();
221 }
222 Language::from_name(lang_name).and_then(|l| self.configs.get(&l))
223 }
224
225 pub fn highlight(&self, code: &str, language: &str) -> Result<String> {
232 let lang =
233 Language::from_name(language).ok_or_else(|| Error::unsupported_language(language))?;
234
235 self.highlight_with_lang(code, &lang)
236 }
237
238 pub fn highlight_with_lang(&self, code: &str, language: &Language) -> Result<String> {
240 let config = self
241 .configs
242 .get(language)
243 .ok_or_else(|| Error::unsupported_language(language.as_str()))?;
244
245 let mut highlighter = Highlighter::new();
246 let highlights = highlighter
247 .highlight(config, code.as_bytes(), None, |lang_name| {
248 self.injected_config(lang_name)
249 })
250 .map_err(|e| Error::syntax_highlight(e.to_string()))?;
251
252 let mut renderer = HtmlRenderer::new();
253 renderer
254 .render(highlights, code.as_bytes(), &|highlight, buf| {
255 buf.extend(self.get_css_class(highlight).as_bytes());
256 })
257 .map_err(|e| Error::syntax_highlight(e.to_string()))?;
258
259 let mut html = String::new();
260 html.push_str("<pre class=\"syntax-highlight\"><code>");
261 for line in renderer.lines() {
262 html.push_str(&html_escape(line));
263 }
264 html.push_str("</code></pre>");
265
266 Ok(html)
267 }
268
269 pub fn highlight_or_fallback(&self, code: &str, language: &str) -> String {
272 self.highlight(code, language)
273 .unwrap_or_else(|_| format!("<pre><code>{}</code></pre>", html_escape(code)))
274 }
275
276 fn get_css_class(&self, highlight: Highlight) -> &'static str {
278 let idx = highlight.0;
279 if idx < THEME_HIGHLIGHT_NAMES.len() {
280 THEME_HIGHLIGHT_NAMES[idx]
281 } else {
282 ""
283 }
284 }
285
286 pub fn theme(&self) -> SyntaxTheme {
288 self.theme
289 }
290
291 pub fn set_theme(&mut self, theme: SyntaxTheme) {
293 self.theme = theme;
294 }
295
296 pub fn is_language_supported(&self, language: &str) -> bool {
298 Language::from_name(language)
299 .map(|lang| self.configs.contains_key(&lang))
300 .unwrap_or(false)
301 }
302
303 pub fn supported_languages(&self) -> Vec<&'static str> {
305 self.configs.keys().map(|lang| lang.as_str()).collect()
306 }
307
308 pub fn generate_stylesheet(&self) -> String {
310 let theme_colors = match self.theme {
311 SyntaxTheme::Light => &LIGHT_THEME_COLORS,
312 SyntaxTheme::Dark => &DARK_THEME_COLORS,
313 SyntaxTheme::HighContrast => &HIGH_CONTRAST_THEME_COLORS,
314 SyntaxTheme::Custom => &DARK_THEME_COLORS, };
316
317 let mut css = String::from(".syntax-highlight {\n");
318 css.push_str(" font-family: 'Fira Code', 'Consolas', monospace;\n");
319 css.push_str(" line-height: 1.5;\n");
320 css.push_str(" overflow-x: auto;\n");
321 css.push_str(" padding: 1em;\n");
322 css.push_str(" border-radius: 4px;\n");
323 css.push_str(&format!(
324 " background-color: {};\n",
325 theme_colors.background
326 ));
327 css.push_str(&format!(" color: {};\n", theme_colors.foreground));
328 css.push_str("}\n\n");
329
330 for (i, name) in THEME_HIGHLIGHT_NAMES.iter().enumerate() {
331 if let Some(color) = theme_colors.highlights.get(i) {
332 css.push_str(&format!(
333 ".syntax-highlight .{} {{ color: {}; }}\n",
334 name, color
335 ));
336 }
337 }
338
339 css
340 }
341}
342
343impl Default for SyntaxHighlighter {
344 fn default() -> Self {
345 Self::new()
346 }
347}
348
349#[allow(clippy::expect_used)]
358fn static_regex(pattern: &str) -> Regex {
359 Regex::new(pattern).expect("validated static regex pattern")
360}
361
362static CODE_BLOCK_REGEX: LazyLock<Regex> =
363 LazyLock::new(|| static_regex(r#"<pre([^>]*)>\s*<code([^>]*)>([\s\S]*?)</code>\s*</pre>"#));
364
365static LANGUAGE_CLASS_REGEX: LazyLock<Regex> =
366 LazyLock::new(|| static_regex(r#"class="language-([^"]*)""#));
367
368static CODE_CONTENT_REGEX: LazyLock<Regex> =
369 LazyLock::new(|| static_regex(r#"<code[^>]*>([\s\S]*?)</code>"#));
370
371pub fn highlight_code_blocks(html: &str, theme: SyntaxTheme) -> String {
378 let highlighter = SyntaxHighlighter::with_theme(theme);
379
380 CODE_BLOCK_REGEX.replace_all(html, |caps: ®ex::Captures| {
381 let _pre_attrs = caps.get(1).map(|m| m.as_str()).unwrap_or("");
382 let code_attrs = caps.get(2).map(|m| m.as_str()).unwrap_or("");
383 let code_html = &caps[3];
384
385 let lang = LANGUAGE_CLASS_REGEX
386 .captures(code_attrs)
387 .and_then(|c| c.get(1))
388 .map(|m| m.as_str());
389
390 let Some(lang) = lang else {
391 return caps[0].to_string();
392 };
393
394 let raw = html_decode(code_html);
395
396 match highlighter.highlight(&raw, lang) {
397 Ok(highlighted) => {
398 format!(
399 r#"<div class="code-block-wrapper"><pre class="syntax-highlight" data-language="{}"><code class="language-{}">{}</code></pre><button class="code-copy-btn" onclick="(function(b){{var c=b.parentElement.querySelector('code');navigator.clipboard.writeText(c.textContent).then(function(){{b.textContent='Copied!';setTimeout(function(){{b.textContent='Copy'}},2000)}})}})(this)" aria-label="Copy code to clipboard">Copy</button></div>"#,
400 lang, lang, extract_inner_code(&highlighted)
401 )
402 }
403 Err(_) => caps[0].to_string(),
404 }
405 })
406 .to_string()
407}
408
409fn extract_inner_code(html: &str) -> String {
410 CODE_CONTENT_REGEX
411 .captures(html)
412 .and_then(|c| c.get(1).map(|m| m.as_str().to_string()))
413 .unwrap_or_else(|| html.to_string())
414}
415
416fn html_decode(s: &str) -> String {
417 s.replace("<", "<")
418 .replace(">", ">")
419 .replace("&", "&")
420 .replace(""", "\"")
421 .replace("'", "'")
422 .replace("'", "'")
423}
424
425fn html_escape(s: &str) -> String {
427 s.replace('&', "&")
428 .replace('<', "<")
429 .replace('>', ">")
430 .replace('"', """)
431 .replace('\'', "'")
432}
433
434const THEME_HIGHLIGHT_NAMES: &[&str] = &[
436 "attribute",
437 "constant",
438 "function.builtin",
439 "function",
440 "keyword",
441 "operator",
442 "property",
443 "punctuation",
444 "punctuation.bracket",
445 "punctuation.delimiter",
446 "string",
447 "string.escape",
448 "string.special",
449 "tag",
450 "type",
451 "type.builtin",
452 "variable",
453 "variable.builtin",
454 "variable.parameter",
455 "comment",
456 "constructor",
457 "embedded",
458 "label",
459 "number",
460 "repeat",
461 "character",
462 "conditional",
463 "define",
464 "include",
465 "boolean",
466];
467
468struct ThemeColors {
470 background: &'static str,
471 foreground: &'static str,
472 highlights: &'static [&'static str],
473}
474
475const DARK_THEME_COLORS: ThemeColors = ThemeColors {
477 background: "#282c34",
478 foreground: "#abb2bf",
479 highlights: &[
480 "#e06c75", "#e5c07b", "#e5c07b", "#61afef", "#c678dd", "#56b6c2", "#e06c75", "#abb2bf", "#abb2bf", "#abb2bf", "#98c379", "#56b6c2", "#56b6c2", "#e06c75", "#e5c07b", "#e5c07b", "#e06c75", "#e5c07b", "#e06c75", "#5c6370", "#e5c07b", "#98c379", "#c678dd", "#d19a66", "#c678dd", "#98c379", "#c678dd", "#c678dd", "#c678dd", "#d19a66", ],
511};
512
513const LIGHT_THEME_COLORS: ThemeColors = ThemeColors {
515 background: "#fafafa",
516 foreground: "#383a42",
517 highlights: &[
518 "#e45649", "#986801", "#a626a4", "#4078f2", "#a626a4", "#0184bc", "#e45649", "#383a42", "#383a42", "#383a42", "#50a14f", "#0184bc", "#0184bc", "#e45649", "#986801", "#c18401", "#e45649", "#986801", "#e45649", "#a0a1a7", "#986801", "#50a14f", "#a626a4", "#986801", "#a626a4", "#50a14f", "#a626a4", "#a626a4", "#a626a4", "#986801", ],
549};
550
551const HIGH_CONTRAST_THEME_COLORS: ThemeColors = ThemeColors {
553 background: "#000000",
554 foreground: "#ffffff",
555 highlights: &[
556 "#ff6b6b", "#ffd93d", "#ffd93d", "#6bcfff", "#ff79c6", "#8be9fd", "#ff6b6b", "#ffffff", "#ffffff", "#ffffff", "#50fa7b", "#8be9fd", "#8be9fd", "#ff6b6b", "#ffd93d", "#ffd93d", "#ff6b6b", "#ffd93d", "#ff6b6b", "#bfbfbf", "#ffd93d", "#50fa7b", "#ff79c6", "#ffb86c", "#ff79c6", "#50fa7b", "#ff79c6", "#ff79c6", "#ff79c6", "#ffb86c", ],
587};
588
589#[cfg(test)]
590mod tests {
591 #![allow(clippy::unwrap_used)]
592 use super::*;
593
594 #[test]
595 fn test_highlighter_creation() {
596 let highlighter = SyntaxHighlighter::new();
597 assert_eq!(highlighter.theme(), SyntaxTheme::Dark);
598 }
599
600 #[test]
601 fn test_rust_highlighting() {
602 let highlighter = SyntaxHighlighter::new();
603 let code = r#"fn main() { println!("Hello"); }"#;
604 let result = highlighter.highlight(code, "rust");
605
606 assert!(result.is_ok());
607 let html = result.unwrap();
608 assert!(html.contains("<pre"));
609 assert!(html.contains("</pre>"));
610 assert!(html.contains("syntax-highlight"));
611 }
612
613 #[test]
614 fn test_python_highlighting() {
615 let highlighter = SyntaxHighlighter::new();
616 let code = "def hello():\n print('Hello')";
617 let result = highlighter.highlight(code, "python");
618
619 assert!(result.is_ok());
620 }
621
622 #[test]
623 #[cfg(feature = "lang-toml")]
624 fn test_toml_highlighting() {
625 let highlighter = SyntaxHighlighter::new();
626 let code = "[package]\nname = \"x\"\nversion = \"0.1.0\"";
627 let result = highlighter.highlight(code, "toml");
628 assert!(
629 result.is_ok(),
630 "TOML highlighting must be available (tree-sitter-toml-ng), got: {:?}",
631 result.err()
632 );
633 }
634
635 #[test]
636 #[cfg(feature = "lang-markdown")]
637 fn test_markdown_highlighting() {
638 let highlighter = SyntaxHighlighter::new();
639 let code = "# Title\n\nSome *inline* text and `code`.";
640 let result = highlighter.highlight(code, "markdown");
641 assert!(
642 result.is_ok(),
643 "Markdown highlighting must be available (tree-sitter-md), got: {:?}",
644 result.err()
645 );
646 }
647
648 #[test]
649 fn test_unsupported_language() {
650 let highlighter = SyntaxHighlighter::new();
651 let code = "some code";
652 let result = highlighter.highlight(code, "unknown_lang");
653
654 assert!(result.is_err());
655 }
656
657 #[test]
658 fn test_highlight_falls_back_to_plain_pre_for_unknown_lang() {
659 let highlighter = SyntaxHighlighter::new();
660 let code = "some <raw> & code";
661 let html = highlighter.highlight_or_fallback(code, "unknown_lang");
662
663 assert!(html.starts_with("<pre><code>"));
664 assert!(html.ends_with("</code></pre>"));
665 assert!(html.contains("<raw>"));
666 assert!(html.contains("&"));
667 assert!(!html.contains("<raw>"), "raw HTML must be escaped");
668 }
669
670 #[test]
671 fn test_highlight_falls_back_for_unsupported_known_lang() {
672 let highlighter = SyntaxHighlighter::new();
673 assert!(!highlighter.is_language_supported("sql"));
675 let html = highlighter.highlight_or_fallback("SELECT 1;", "sql");
676 assert!(html.starts_with("<pre><code>SELECT 1;</code></pre>"));
677 }
678
679 #[test]
680 #[cfg(all(
681 feature = "lang-rust",
682 feature = "lang-python",
683 feature = "lang-javascript",
684 feature = "lang-toml"
685 ))]
686 fn test_is_language_supported() {
687 let highlighter = SyntaxHighlighter::new();
688
689 assert!(highlighter.is_language_supported("rust"));
690 assert!(highlighter.is_language_supported("python"));
691 assert!(highlighter.is_language_supported("js"));
692 assert!(highlighter.is_language_supported("toml"));
693 assert!(!highlighter.is_language_supported("unknown"));
694 }
695
696 #[test]
697 fn test_stylesheet_generation() {
698 let highlighter = SyntaxHighlighter::new();
699 let css = highlighter.generate_stylesheet();
700
701 assert!(css.contains(".syntax-highlight"));
702 assert!(css.contains("background-color"));
703 }
704
705 #[test]
706 fn test_theme_switching() {
707 let mut highlighter = SyntaxHighlighter::new();
708 assert_eq!(highlighter.theme(), SyntaxTheme::Dark);
709
710 highlighter.set_theme(SyntaxTheme::Light);
711 assert_eq!(highlighter.theme(), SyntaxTheme::Light);
712
713 let css = highlighter.generate_stylesheet();
714 assert!(css.contains("#fafafa")); }
716
717 #[test]
718 fn test_html_escape() {
719 let escaped = html_escape("<script>alert('xss')</script>");
720 assert!(escaped.contains("<"));
721 assert!(escaped.contains(">"));
722 assert!(!escaped.contains("<script>"));
723 }
724
725 #[test]
728 fn highlight_rust_code_block() {
729 let html =
730 r#"<pre><code class="language-rust">fn main() { println!("Hello"); }</code></pre>"#;
731 let result = highlight_code_blocks(html, SyntaxTheme::Dark);
732 assert!(result.contains("syntax-highlight"));
733 assert!(result.contains("data-language=\"rust\""));
734 assert!(result.contains("code-block-wrapper"));
735 assert!(
736 !result.starts_with(html.trim_start()),
737 "block should be replaced"
738 );
739 }
740
741 #[test]
742 fn highlight_preserves_unknown_language() {
743 let html = r#"<pre><code class="language-brainfuck">+++[>+++<-]</code></pre>"#;
744 let result = highlight_code_blocks(html, SyntaxTheme::Dark);
745 assert_eq!(result, html);
746 }
747
748 #[test]
749 fn highlight_preserves_no_language_block() {
750 let html = r#"<pre><code>some plain text</code></pre>"#;
751 let result = highlight_code_blocks(html, SyntaxTheme::Dark);
752 assert_eq!(result, html);
753 }
754
755 #[test]
756 fn html_decode_roundtrip() {
757 assert_eq!(html_decode("<script>"), "<script>");
758 assert_eq!(html_decode("&"), "&");
759 }
760}