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 #[allow(unused_mut)]
52 let mut configs = std::collections::HashMap::new();
53
54 #[cfg(feature = "lang-rust")]
55 if let Ok(mut config) = HighlightConfiguration::new(
56 tree_sitter_rust::LANGUAGE.into(),
57 "rust",
58 tree_sitter_rust::HIGHLIGHTS_QUERY,
59 tree_sitter_rust::INJECTIONS_QUERY,
60 "",
61 ) {
62 config.configure(THEME_HIGHLIGHT_NAMES);
63 configs.insert(Language::Rust, config);
64 }
65
66 #[cfg(feature = "lang-python")]
67 if let Ok(mut config) = HighlightConfiguration::new(
68 tree_sitter_python::LANGUAGE.into(),
69 "python",
70 tree_sitter_python::HIGHLIGHTS_QUERY,
71 "",
72 "",
73 ) {
74 config.configure(THEME_HIGHLIGHT_NAMES);
75 configs.insert(Language::Python, config);
76 }
77
78 #[cfg(feature = "lang-javascript")]
79 if let Ok(mut config) = HighlightConfiguration::new(
80 tree_sitter_javascript::LANGUAGE.into(),
81 "javascript",
82 tree_sitter_javascript::HIGHLIGHT_QUERY,
83 tree_sitter_javascript::INJECTIONS_QUERY,
84 "",
85 ) {
86 config.configure(THEME_HIGHLIGHT_NAMES);
87 configs.insert(Language::JavaScript, config);
88 }
89
90 #[cfg(feature = "lang-typescript")]
91 if let Ok(mut config) = HighlightConfiguration::new(
92 tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
93 "typescript",
94 tree_sitter_typescript::HIGHLIGHTS_QUERY,
95 "",
96 "",
97 ) {
98 config.configure(THEME_HIGHLIGHT_NAMES);
99 configs.insert(Language::TypeScript, config);
100 }
101
102 #[cfg(feature = "lang-json")]
103 if let Ok(mut config) = HighlightConfiguration::new(
104 tree_sitter_json::LANGUAGE.into(),
105 "json",
106 tree_sitter_json::HIGHLIGHTS_QUERY,
107 "",
108 "",
109 ) {
110 config.configure(THEME_HIGHLIGHT_NAMES);
111 configs.insert(Language::Json, config);
112 }
113
114 #[cfg(feature = "lang-toml")]
115 if let Ok(mut config) = HighlightConfiguration::new(
116 tree_sitter_toml_ng::LANGUAGE.into(),
117 "toml",
118 tree_sitter_toml_ng::HIGHLIGHTS_QUERY,
119 "",
120 "",
121 ) {
122 config.configure(THEME_HIGHLIGHT_NAMES);
123 configs.insert(Language::Toml, config);
124 }
125
126 #[cfg(feature = "lang-yaml")]
127 if let Ok(mut config) = HighlightConfiguration::new(
128 tree_sitter_yaml::LANGUAGE.into(),
129 "yaml",
130 tree_sitter_yaml::HIGHLIGHTS_QUERY,
131 "",
132 "",
133 ) {
134 config.configure(THEME_HIGHLIGHT_NAMES);
135 configs.insert(Language::Yaml, config);
136 }
137
138 #[cfg(feature = "lang-html")]
139 if let Ok(mut config) = HighlightConfiguration::new(
140 tree_sitter_html::LANGUAGE.into(),
141 "html",
142 tree_sitter_html::HIGHLIGHTS_QUERY,
143 tree_sitter_html::INJECTIONS_QUERY,
144 "",
145 ) {
146 config.configure(THEME_HIGHLIGHT_NAMES);
147 configs.insert(Language::Html, config);
148 }
149
150 #[cfg(feature = "lang-css")]
151 if let Ok(mut config) = HighlightConfiguration::new(
152 tree_sitter_css::LANGUAGE.into(),
153 "css",
154 tree_sitter_css::HIGHLIGHTS_QUERY,
155 "",
156 "",
157 ) {
158 config.configure(THEME_HIGHLIGHT_NAMES);
159 configs.insert(Language::Css, config);
160 }
161
162 #[cfg(feature = "lang-bash")]
163 if let Ok(mut config) = HighlightConfiguration::new(
164 tree_sitter_bash::LANGUAGE.into(),
165 "bash",
166 tree_sitter_bash::HIGHLIGHT_QUERY,
167 "",
168 "",
169 ) {
170 config.configure(THEME_HIGHLIGHT_NAMES);
171 configs.insert(Language::Bash, config);
172 }
173
174 #[cfg(feature = "lang-markdown")]
175 if let Ok(mut config) = HighlightConfiguration::new(
176 tree_sitter_md::LANGUAGE.into(),
177 "markdown",
178 tree_sitter_md::HIGHLIGHT_QUERY_BLOCK,
179 tree_sitter_md::INJECTION_QUERY_BLOCK,
180 "",
181 ) {
182 config.configure(THEME_HIGHLIGHT_NAMES);
183 configs.insert(Language::Markdown, config);
184 }
185
186 #[cfg(feature = "lang-markdown")]
190 {
191 let _ = MARKDOWN_INLINE_CONFIG.get_or_init(|| {
192 HighlightConfiguration::new(
193 tree_sitter_md::INLINE_LANGUAGE.into(),
194 "markdown-inline",
195 tree_sitter_md::HIGHLIGHT_QUERY_INLINE,
196 tree_sitter_md::INJECTION_QUERY_INLINE,
197 "",
198 )
199 .map(|mut c| {
200 c.configure(THEME_HIGHLIGHT_NAMES);
201 c
202 })
203 .ok()
204 });
205 }
206
207 debug!(
212 "Initialized {} syntax highlight configurations",
213 configs.len()
214 );
215 configs
216 }
217
218 fn injected_config(&self, lang_name: &str) -> Option<&HighlightConfiguration> {
220 #[cfg(feature = "lang-markdown")]
221 if lang_name == "markdown-inline" || lang_name == "markdown_inline" {
222 return MARKDOWN_INLINE_CONFIG.get_or_init(|| None).as_ref();
223 }
224 Language::from_name(lang_name).and_then(|l| self.configs.get(&l))
225 }
226
227 pub fn highlight(&self, code: &str, language: &str) -> Result<String> {
234 let lang =
235 Language::from_name(language).ok_or_else(|| Error::unsupported_language(language))?;
236
237 self.highlight_with_lang(code, &lang)
238 }
239
240 pub fn highlight_with_lang(&self, code: &str, language: &Language) -> Result<String> {
242 let config = self
243 .configs
244 .get(language)
245 .ok_or_else(|| Error::unsupported_language(language.as_str()))?;
246
247 let mut highlighter = Highlighter::new();
248 let highlights = highlighter
249 .highlight(config, code.as_bytes(), None, |lang_name| {
250 self.injected_config(lang_name)
251 })
252 .map_err(|e| Error::syntax_highlight(e.to_string()))?;
253
254 let mut renderer = HtmlRenderer::new();
255 renderer
256 .render(highlights, code.as_bytes(), &|highlight, buf| {
257 buf.extend(self.get_css_class(highlight).as_bytes());
258 })
259 .map_err(|e| Error::syntax_highlight(e.to_string()))?;
260
261 let mut html = String::new();
262 html.push_str("<pre class=\"syntax-highlight\"><code>");
263 for line in renderer.lines() {
264 html.push_str(&html_escape(line));
265 }
266 html.push_str("</code></pre>");
267
268 Ok(html)
269 }
270
271 pub fn highlight_or_fallback(&self, code: &str, language: &str) -> String {
274 self.highlight(code, language)
275 .unwrap_or_else(|_| format!("<pre><code>{}</code></pre>", html_escape(code)))
276 }
277
278 fn get_css_class(&self, highlight: Highlight) -> &'static str {
280 let idx = highlight.0;
281 if idx < THEME_HIGHLIGHT_NAMES.len() {
282 THEME_HIGHLIGHT_NAMES[idx]
283 } else {
284 ""
285 }
286 }
287
288 pub fn theme(&self) -> SyntaxTheme {
290 self.theme
291 }
292
293 pub fn set_theme(&mut self, theme: SyntaxTheme) {
295 self.theme = theme;
296 }
297
298 pub fn is_language_supported(&self, language: &str) -> bool {
300 Language::from_name(language)
301 .map(|lang| self.configs.contains_key(&lang))
302 .unwrap_or(false)
303 }
304
305 pub fn supported_languages(&self) -> Vec<&'static str> {
307 self.configs.keys().map(|lang| lang.as_str()).collect()
308 }
309
310 pub fn generate_stylesheet(&self) -> String {
312 let theme_colors = match self.theme {
313 SyntaxTheme::Light => &LIGHT_THEME_COLORS,
314 SyntaxTheme::Dark => &DARK_THEME_COLORS,
315 SyntaxTheme::HighContrast => &HIGH_CONTRAST_THEME_COLORS,
316 SyntaxTheme::Custom => &DARK_THEME_COLORS, };
318
319 let mut css = String::from(".syntax-highlight {\n");
320 css.push_str(" font-family: 'Fira Code', 'Consolas', monospace;\n");
321 css.push_str(" line-height: 1.5;\n");
322 css.push_str(" overflow-x: auto;\n");
323 css.push_str(" padding: 1em;\n");
324 css.push_str(" border-radius: 4px;\n");
325 css.push_str(&format!(
326 " background-color: {};\n",
327 theme_colors.background
328 ));
329 css.push_str(&format!(" color: {};\n", theme_colors.foreground));
330 css.push_str("}\n\n");
331
332 for (i, name) in THEME_HIGHLIGHT_NAMES.iter().enumerate() {
333 if let Some(color) = theme_colors.highlights.get(i) {
334 css.push_str(&format!(
335 ".syntax-highlight .{} {{ color: {}; }}\n",
336 name, color
337 ));
338 }
339 }
340
341 css
342 }
343}
344
345impl Default for SyntaxHighlighter {
346 fn default() -> Self {
347 Self::new()
348 }
349}
350
351#[allow(clippy::expect_used)]
360fn static_regex(pattern: &str) -> Regex {
361 Regex::new(pattern).expect("validated static regex pattern")
362}
363
364static CODE_BLOCK_REGEX: LazyLock<Regex> =
365 LazyLock::new(|| static_regex(r#"<pre([^>]*)>\s*<code([^>]*)>([\s\S]*?)</code>\s*</pre>"#));
366
367static LANGUAGE_CLASS_REGEX: LazyLock<Regex> =
368 LazyLock::new(|| static_regex(r#"class="language-([^"]*)""#));
369
370static CODE_CONTENT_REGEX: LazyLock<Regex> =
371 LazyLock::new(|| static_regex(r#"<code[^>]*>([\s\S]*?)</code>"#));
372
373pub fn highlight_code_blocks(html: &str, theme: SyntaxTheme) -> String {
382 let highlighter = SyntaxHighlighter::with_theme(theme);
383
384 CODE_BLOCK_REGEX.replace_all(html, |caps: ®ex::Captures| {
385 let _pre_attrs = caps.get(1).map(|m| m.as_str()).unwrap_or("");
386 let code_attrs = caps.get(2).map(|m| m.as_str()).unwrap_or("");
387 let code_html = &caps[3];
388
389 let lang = LANGUAGE_CLASS_REGEX
390 .captures(code_attrs)
391 .and_then(|c| c.get(1))
392 .map(|m| m.as_str());
393
394 let Some(lang) = lang else {
395 return caps[0].to_string();
396 };
397
398 let raw = html_decode(code_html);
399
400 match highlighter.highlight(&raw, lang) {
401 Ok(highlighted) => {
402 format!(
403 r#"<div class="code-block-wrapper"><pre class="syntax-highlight" data-language="{}" data-theme="{}"><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>"#,
404 lang,
405 theme_slug(theme),
406 lang,
407 extract_inner_code(&highlighted)
408 )
409 }
410 Err(_) => caps[0].to_string(),
411 }
412 })
413 .to_string()
414}
415
416fn theme_slug(theme: SyntaxTheme) -> &'static str {
418 match theme {
419 SyntaxTheme::Light => "light",
420 SyntaxTheme::Dark => "dark",
421 SyntaxTheme::HighContrast => "high-contrast",
422 SyntaxTheme::Custom => "custom",
423 }
424}
425
426fn extract_inner_code(html: &str) -> String {
427 CODE_CONTENT_REGEX
428 .captures(html)
429 .and_then(|c| c.get(1).map(|m| m.as_str().to_string()))
430 .unwrap_or_else(|| html.to_string())
431}
432
433fn html_decode(s: &str) -> String {
434 s.replace("<", "<")
435 .replace(">", ">")
436 .replace("&", "&")
437 .replace(""", "\"")
438 .replace("'", "'")
439 .replace("'", "'")
440}
441
442fn html_escape(s: &str) -> String {
444 s.replace('&', "&")
445 .replace('<', "<")
446 .replace('>', ">")
447 .replace('"', """)
448 .replace('\'', "'")
449}
450
451const THEME_HIGHLIGHT_NAMES: &[&str] = &[
453 "attribute",
454 "constant",
455 "function.builtin",
456 "function",
457 "keyword",
458 "operator",
459 "property",
460 "punctuation",
461 "punctuation.bracket",
462 "punctuation.delimiter",
463 "string",
464 "string.escape",
465 "string.special",
466 "tag",
467 "type",
468 "type.builtin",
469 "variable",
470 "variable.builtin",
471 "variable.parameter",
472 "comment",
473 "constructor",
474 "embedded",
475 "label",
476 "number",
477 "repeat",
478 "character",
479 "conditional",
480 "define",
481 "include",
482 "boolean",
483];
484
485struct ThemeColors {
487 background: &'static str,
488 foreground: &'static str,
489 highlights: &'static [&'static str],
490}
491
492const DARK_THEME_COLORS: ThemeColors = ThemeColors {
494 background: "#282c34",
495 foreground: "#abb2bf",
496 highlights: &[
497 "#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", ],
528};
529
530const LIGHT_THEME_COLORS: ThemeColors = ThemeColors {
532 background: "#fafafa",
533 foreground: "#383a42",
534 highlights: &[
535 "#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", ],
566};
567
568const HIGH_CONTRAST_THEME_COLORS: ThemeColors = ThemeColors {
570 background: "#000000",
571 foreground: "#ffffff",
572 highlights: &[
573 "#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", ],
604};
605
606#[cfg(test)]
607mod tests {
608 #![allow(clippy::unwrap_used)]
609 use super::*;
610
611 #[test]
612 fn test_highlighter_creation() {
613 let highlighter = SyntaxHighlighter::new();
614 assert_eq!(highlighter.theme(), SyntaxTheme::Dark);
615 }
616
617 #[test]
618 #[cfg(feature = "lang-rust")]
619 fn test_rust_highlighting() {
620 let highlighter = SyntaxHighlighter::new();
621 let code = r#"fn main() { println!("Hello"); }"#;
622 let result = highlighter.highlight(code, "rust");
623
624 assert!(result.is_ok());
625 let html = result.unwrap();
626 assert!(html.contains("<pre"));
627 assert!(html.contains("</pre>"));
628 assert!(html.contains("syntax-highlight"));
629 }
630
631 #[test]
632 #[cfg(feature = "lang-python")]
633 fn test_python_highlighting() {
634 let highlighter = SyntaxHighlighter::new();
635 let code = "def hello():\n print('Hello')";
636 let result = highlighter.highlight(code, "python");
637
638 assert!(result.is_ok());
639 }
640
641 #[test]
642 #[cfg(feature = "lang-toml")]
643 fn test_toml_highlighting() {
644 let highlighter = SyntaxHighlighter::new();
645 let code = "[package]\nname = \"x\"\nversion = \"0.1.0\"";
646 let result = highlighter.highlight(code, "toml");
647 assert!(
648 result.is_ok(),
649 "TOML highlighting must be available (tree-sitter-toml-ng), got: {:?}",
650 result.err()
651 );
652 }
653
654 #[test]
655 #[cfg(feature = "lang-markdown")]
656 fn test_markdown_highlighting() {
657 let highlighter = SyntaxHighlighter::new();
658 let code = "# Title\n\nSome *inline* text and `code`.";
659 let result = highlighter.highlight(code, "markdown");
660 assert!(
661 result.is_ok(),
662 "Markdown highlighting must be available (tree-sitter-md), got: {:?}",
663 result.err()
664 );
665 }
666
667 #[test]
668 fn test_unsupported_language() {
669 let highlighter = SyntaxHighlighter::new();
670 let code = "some code";
671 let result = highlighter.highlight(code, "unknown_lang");
672
673 assert!(result.is_err());
674 }
675
676 #[test]
677 fn test_highlight_falls_back_to_plain_pre_for_unknown_lang() {
678 let highlighter = SyntaxHighlighter::new();
679 let code = "some <raw> & code";
680 let html = highlighter.highlight_or_fallback(code, "unknown_lang");
681
682 assert!(html.starts_with("<pre><code>"));
683 assert!(html.ends_with("</code></pre>"));
684 assert!(html.contains("<raw>"));
685 assert!(html.contains("&"));
686 assert!(!html.contains("<raw>"), "raw HTML must be escaped");
687 }
688
689 #[test]
690 fn test_highlight_falls_back_for_unsupported_known_lang() {
691 let highlighter = SyntaxHighlighter::new();
692 assert!(!highlighter.is_language_supported("sql"));
694 let html = highlighter.highlight_or_fallback("SELECT 1;", "sql");
695 assert!(html.starts_with("<pre><code>SELECT 1;</code></pre>"));
696 }
697
698 #[test]
699 #[cfg(all(
700 feature = "lang-rust",
701 feature = "lang-python",
702 feature = "lang-javascript",
703 feature = "lang-toml"
704 ))]
705 fn test_is_language_supported() {
706 let highlighter = SyntaxHighlighter::new();
707
708 assert!(highlighter.is_language_supported("rust"));
709 assert!(highlighter.is_language_supported("python"));
710 assert!(highlighter.is_language_supported("js"));
711 assert!(highlighter.is_language_supported("toml"));
712 assert!(!highlighter.is_language_supported("unknown"));
713 }
714
715 #[test]
716 fn test_stylesheet_generation() {
717 let highlighter = SyntaxHighlighter::new();
718 let css = highlighter.generate_stylesheet();
719
720 assert!(css.contains(".syntax-highlight"));
721 assert!(css.contains("background-color"));
722 }
723
724 #[test]
725 fn test_theme_switching() {
726 let mut highlighter = SyntaxHighlighter::new();
727 assert_eq!(highlighter.theme(), SyntaxTheme::Dark);
728
729 highlighter.set_theme(SyntaxTheme::Light);
730 assert_eq!(highlighter.theme(), SyntaxTheme::Light);
731
732 let css = highlighter.generate_stylesheet();
733 assert!(css.contains("#fafafa")); }
735
736 #[test]
737 fn test_html_escape() {
738 let escaped = html_escape("<script>alert('xss')</script>");
739 assert!(escaped.contains("<"));
740 assert!(escaped.contains(">"));
741 assert!(!escaped.contains("<script>"));
742 }
743
744 #[test]
747 #[cfg(feature = "lang-rust")]
748 fn highlight_rust_code_block() {
749 let html =
750 r#"<pre><code class="language-rust">fn main() { println!("Hello"); }</code></pre>"#;
751 let result = highlight_code_blocks(html, SyntaxTheme::Dark);
752 assert!(result.contains("syntax-highlight"));
753 assert!(result.contains("data-language=\"rust\""));
754 assert!(result.contains("code-block-wrapper"));
755 assert!(
756 !result.starts_with(html.trim_start()),
757 "block should be replaced"
758 );
759 }
760
761 #[test]
762 fn highlight_preserves_unknown_language() {
763 let html = r#"<pre><code class="language-brainfuck">+++[>+++<-]</code></pre>"#;
764 let result = highlight_code_blocks(html, SyntaxTheme::Dark);
765 assert_eq!(result, html);
766 }
767
768 #[test]
769 fn highlight_preserves_no_language_block() {
770 let html = r#"<pre><code>some plain text</code></pre>"#;
771 let result = highlight_code_blocks(html, SyntaxTheme::Dark);
772 assert_eq!(result, html);
773 }
774
775 #[test]
776 fn html_decode_roundtrip() {
777 assert_eq!(html_decode("<script>"), "<script>");
778 assert_eq!(html_decode("&"), "&");
779 }
780}