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
349static CODE_BLOCK_REGEX: LazyLock<Regex> = LazyLock::new(|| {
354 Regex::new(r#"<pre([^>]*)>\s*<code([^>]*)>([\s\S]*?)</code>\s*</pre>"#).unwrap()
355});
356
357static LANGUAGE_CLASS_REGEX: LazyLock<Regex> =
358 LazyLock::new(|| Regex::new(r#"class="language-([^"]*)""#).unwrap());
359
360static CODE_CONTENT_REGEX: LazyLock<Regex> =
361 LazyLock::new(|| Regex::new(r#"<code[^>]*>([\s\S]*?)</code>"#).unwrap());
362
363pub fn highlight_code_blocks(html: &str, theme: SyntaxTheme) -> String {
370 let highlighter = SyntaxHighlighter::with_theme(theme);
371
372 CODE_BLOCK_REGEX.replace_all(html, |caps: ®ex::Captures| {
373 let _pre_attrs = caps.get(1).map(|m| m.as_str()).unwrap_or("");
374 let code_attrs = caps.get(2).map(|m| m.as_str()).unwrap_or("");
375 let code_html = &caps[3];
376
377 let lang = LANGUAGE_CLASS_REGEX
378 .captures(code_attrs)
379 .and_then(|c| c.get(1))
380 .map(|m| m.as_str());
381
382 let Some(lang) = lang else {
383 return caps[0].to_string();
384 };
385
386 let raw = html_decode(code_html);
387
388 match highlighter.highlight(&raw, lang) {
389 Ok(highlighted) => {
390 format!(
391 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>"#,
392 lang, lang, extract_inner_code(&highlighted)
393 )
394 }
395 Err(_) => caps[0].to_string(),
396 }
397 })
398 .to_string()
399}
400
401fn extract_inner_code(html: &str) -> String {
402 CODE_CONTENT_REGEX
403 .captures(html)
404 .and_then(|c| c.get(1).map(|m| m.as_str().to_string()))
405 .unwrap_or_else(|| html.to_string())
406}
407
408fn html_decode(s: &str) -> String {
409 s.replace("<", "<")
410 .replace(">", ">")
411 .replace("&", "&")
412 .replace(""", "\"")
413 .replace("'", "'")
414 .replace("'", "'")
415}
416
417fn html_escape(s: &str) -> String {
419 s.replace('&', "&")
420 .replace('<', "<")
421 .replace('>', ">")
422 .replace('"', """)
423 .replace('\'', "'")
424}
425
426const THEME_HIGHLIGHT_NAMES: &[&str] = &[
428 "attribute",
429 "constant",
430 "function.builtin",
431 "function",
432 "keyword",
433 "operator",
434 "property",
435 "punctuation",
436 "punctuation.bracket",
437 "punctuation.delimiter",
438 "string",
439 "string.escape",
440 "string.special",
441 "tag",
442 "type",
443 "type.builtin",
444 "variable",
445 "variable.builtin",
446 "variable.parameter",
447 "comment",
448 "constructor",
449 "embedded",
450 "label",
451 "number",
452 "repeat",
453 "character",
454 "conditional",
455 "define",
456 "include",
457 "boolean",
458];
459
460struct ThemeColors {
462 background: &'static str,
463 foreground: &'static str,
464 highlights: &'static [&'static str],
465}
466
467const DARK_THEME_COLORS: ThemeColors = ThemeColors {
469 background: "#282c34",
470 foreground: "#abb2bf",
471 highlights: &[
472 "#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", ],
503};
504
505const LIGHT_THEME_COLORS: ThemeColors = ThemeColors {
507 background: "#fafafa",
508 foreground: "#383a42",
509 highlights: &[
510 "#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", ],
541};
542
543const HIGH_CONTRAST_THEME_COLORS: ThemeColors = ThemeColors {
545 background: "#000000",
546 foreground: "#ffffff",
547 highlights: &[
548 "#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", ],
579};
580
581#[cfg(test)]
582mod tests {
583 use super::*;
584
585 #[test]
586 fn test_highlighter_creation() {
587 let highlighter = SyntaxHighlighter::new();
588 assert_eq!(highlighter.theme(), SyntaxTheme::Dark);
589 }
590
591 #[test]
592 fn test_rust_highlighting() {
593 let highlighter = SyntaxHighlighter::new();
594 let code = r#"fn main() { println!("Hello"); }"#;
595 let result = highlighter.highlight(code, "rust");
596
597 assert!(result.is_ok());
598 let html = result.unwrap();
599 assert!(html.contains("<pre"));
600 assert!(html.contains("</pre>"));
601 assert!(html.contains("syntax-highlight"));
602 }
603
604 #[test]
605 fn test_python_highlighting() {
606 let highlighter = SyntaxHighlighter::new();
607 let code = "def hello():\n print('Hello')";
608 let result = highlighter.highlight(code, "python");
609
610 assert!(result.is_ok());
611 }
612
613 #[test]
614 #[cfg(feature = "lang-toml")]
615 fn test_toml_highlighting() {
616 let highlighter = SyntaxHighlighter::new();
617 let code = "[package]\nname = \"x\"\nversion = \"0.1.0\"";
618 let result = highlighter.highlight(code, "toml");
619 assert!(
620 result.is_ok(),
621 "TOML highlighting must be available (tree-sitter-toml-ng), got: {:?}",
622 result.err()
623 );
624 }
625
626 #[test]
627 #[cfg(feature = "lang-markdown")]
628 fn test_markdown_highlighting() {
629 let highlighter = SyntaxHighlighter::new();
630 let code = "# Title\n\nSome *inline* text and `code`.";
631 let result = highlighter.highlight(code, "markdown");
632 assert!(
633 result.is_ok(),
634 "Markdown highlighting must be available (tree-sitter-md), got: {:?}",
635 result.err()
636 );
637 }
638
639 #[test]
640 fn test_unsupported_language() {
641 let highlighter = SyntaxHighlighter::new();
642 let code = "some code";
643 let result = highlighter.highlight(code, "unknown_lang");
644
645 assert!(result.is_err());
646 }
647
648 #[test]
649 fn test_highlight_falls_back_to_plain_pre_for_unknown_lang() {
650 let highlighter = SyntaxHighlighter::new();
651 let code = "some <raw> & code";
652 let html = highlighter.highlight_or_fallback(code, "unknown_lang");
653
654 assert!(html.starts_with("<pre><code>"));
655 assert!(html.ends_with("</code></pre>"));
656 assert!(html.contains("<raw>"));
657 assert!(html.contains("&"));
658 assert!(!html.contains("<raw>"), "raw HTML must be escaped");
659 }
660
661 #[test]
662 fn test_highlight_falls_back_for_unsupported_known_lang() {
663 let highlighter = SyntaxHighlighter::new();
664 assert!(!highlighter.is_language_supported("sql"));
666 let html = highlighter.highlight_or_fallback("SELECT 1;", "sql");
667 assert!(html.starts_with("<pre><code>SELECT 1;</code></pre>"));
668 }
669
670 #[test]
671 #[cfg(all(
672 feature = "lang-rust",
673 feature = "lang-python",
674 feature = "lang-javascript",
675 feature = "lang-toml"
676 ))]
677 fn test_is_language_supported() {
678 let highlighter = SyntaxHighlighter::new();
679
680 assert!(highlighter.is_language_supported("rust"));
681 assert!(highlighter.is_language_supported("python"));
682 assert!(highlighter.is_language_supported("js"));
683 assert!(highlighter.is_language_supported("toml"));
684 assert!(!highlighter.is_language_supported("unknown"));
685 }
686
687 #[test]
688 fn test_stylesheet_generation() {
689 let highlighter = SyntaxHighlighter::new();
690 let css = highlighter.generate_stylesheet();
691
692 assert!(css.contains(".syntax-highlight"));
693 assert!(css.contains("background-color"));
694 }
695
696 #[test]
697 fn test_theme_switching() {
698 let mut highlighter = SyntaxHighlighter::new();
699 assert_eq!(highlighter.theme(), SyntaxTheme::Dark);
700
701 highlighter.set_theme(SyntaxTheme::Light);
702 assert_eq!(highlighter.theme(), SyntaxTheme::Light);
703
704 let css = highlighter.generate_stylesheet();
705 assert!(css.contains("#fafafa")); }
707
708 #[test]
709 fn test_html_escape() {
710 let escaped = html_escape("<script>alert('xss')</script>");
711 assert!(escaped.contains("<"));
712 assert!(escaped.contains(">"));
713 assert!(!escaped.contains("<script>"));
714 }
715
716 #[test]
719 fn highlight_rust_code_block() {
720 let html =
721 r#"<pre><code class="language-rust">fn main() { println!("Hello"); }</code></pre>"#;
722 let result = highlight_code_blocks(html, SyntaxTheme::Dark);
723 assert!(result.contains("syntax-highlight"));
724 assert!(result.contains("data-language=\"rust\""));
725 assert!(result.contains("code-block-wrapper"));
726 assert!(
727 !result.starts_with(html.trim_start()),
728 "block should be replaced"
729 );
730 }
731
732 #[test]
733 fn highlight_preserves_unknown_language() {
734 let html = r#"<pre><code class="language-brainfuck">+++[>+++<-]</code></pre>"#;
735 let result = highlight_code_blocks(html, SyntaxTheme::Dark);
736 assert_eq!(result, html);
737 }
738
739 #[test]
740 fn highlight_preserves_no_language_block() {
741 let html = r#"<pre><code>some plain text</code></pre>"#;
742 let result = highlight_code_blocks(html, SyntaxTheme::Dark);
743 assert_eq!(result, html);
744 }
745
746 #[test]
747 fn html_decode_roundtrip() {
748 assert_eq!(html_decode("<script>"), "<script>");
749 assert_eq!(html_decode("&"), "&");
750 }
751}