Skip to main content

docs_pipeline/
syntax.rs

1//! Syntax highlighting with tree-sitter.
2//!
3//! This module provides syntax highlighting using `tree-sitter-highlight`
4//! with one grammar per enabled `lang-*` cargo feature. Languages whose
5//! feature is disabled report as unsupported at runtime.
6//!
7//! All grammar crates communicate through the version-agnostic
8//! `tree-sitter-language` ABI, so a single `tree-sitter` generation (0.25)
9//! works for every bundled language.
10
11use 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
18/// Global highlight configurations cache
19static HIGHLIGHT_CONFIGS: OnceLock<std::collections::HashMap<Language, HighlightConfiguration>> =
20    OnceLock::new();
21
22/// Markdown has a split grammar (block + inline); the inline configuration is
23/// resolved via the injection callback when the block grammar injects
24/// `markdown-inline`.
25#[cfg(feature = "lang-markdown")]
26static MARKDOWN_INLINE_CONFIG: OnceLock<Option<HighlightConfiguration>> = OnceLock::new();
27
28/// Syntax highlighter for highlighting code
29pub struct SyntaxHighlighter {
30    /// Current theme
31    theme: SyntaxTheme,
32    /// Cached highlight configurations
33    configs: &'static std::collections::HashMap<Language, HighlightConfiguration>,
34}
35
36impl SyntaxHighlighter {
37    /// Create a new syntax highlighter
38    pub fn new() -> Self {
39        Self::with_theme(SyntaxTheme::default())
40    }
41
42    /// Create a new syntax highlighter with a specific theme
43    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    /// Initialize all highlight configurations
49    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        // The markdown block grammar injects `markdown-inline` for inline
185        // content; register the inline configuration for the injection
186        // callback below.
187        #[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        // SQL is a known language name but has no bundled grammar.
206        // Note: SQL highlighting requires an external grammar; falls back to
207        // plain output.
208
209        debug!(
210            "Initialized {} syntax highlight configurations",
211            configs.len()
212        );
213        configs
214    }
215
216    /// Resolve a highlight configuration for an injected language name.
217    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    /// Highlight code and return HTML
226    ///
227    /// # Errors
228    ///
229    /// Returns [`Error::UnsupportedLanguage`] when the language name is not
230    /// recognized or its `lang-*` feature is disabled.
231    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    /// Highlight code with a known language
239    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    /// Highlight code, falling back to a plain (escaped) `<pre><code>` block
270    /// when the language is unknown or unsupported.
271    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    /// Get CSS class for a highlight
277    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    /// Get the current theme
287    pub fn theme(&self) -> SyntaxTheme {
288        self.theme
289    }
290
291    /// Set the theme
292    pub fn set_theme(&mut self, theme: SyntaxTheme) {
293        self.theme = theme;
294    }
295
296    /// Check if a language is supported
297    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    /// Get list of supported languages
304    pub fn supported_languages(&self) -> Vec<&'static str> {
305        self.configs.keys().map(|lang| lang.as_str()).collect()
306    }
307
308    /// Generate CSS stylesheet for the current theme
309    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, // Default to dark for custom
315        };
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// ============================================================================
350// Rendered-HTML code block highlighting
351// ============================================================================
352
353static 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
363/// Highlight all `<pre><code class="language-...">` blocks in rendered HTML.
364///
365/// Each recognized block is replaced with
366/// `<div class="code-block-wrapper"><pre class="syntax-highlight" data-language="...">…</pre>`
367/// plus a copy-to-clipboard button, matching the docs-site rendering pipeline.
368/// Blocks with unknown or unsupported languages are left untouched.
369pub 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: &regex::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("&lt;", "<")
410        .replace("&gt;", ">")
411        .replace("&amp;", "&")
412        .replace("&quot;", "\"")
413        .replace("&#39;", "'")
414        .replace("&#x27;", "'")
415}
416
417/// HTML escape helper
418fn html_escape(s: &str) -> String {
419    s.replace('&', "&amp;")
420        .replace('<', "&lt;")
421        .replace('>', "&gt;")
422        .replace('"', "&quot;")
423        .replace('\'', "&#39;")
424}
425
426/// Theme highlight names (standard tree-sitter highlight names)
427const 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
460/// Theme color definitions
461struct ThemeColors {
462    background: &'static str,
463    foreground: &'static str,
464    highlights: &'static [&'static str],
465}
466
467/// Dark theme colors (similar to One Dark)
468const DARK_THEME_COLORS: ThemeColors = ThemeColors {
469    background: "#282c34",
470    foreground: "#abb2bf",
471    highlights: &[
472        "#e06c75", // attribute
473        "#e5c07b", // constant
474        "#e5c07b", // function.builtin
475        "#61afef", // function
476        "#c678dd", // keyword
477        "#56b6c2", // operator
478        "#e06c75", // property
479        "#abb2bf", // punctuation
480        "#abb2bf", // punctuation.bracket
481        "#abb2bf", // punctuation.delimiter
482        "#98c379", // string
483        "#56b6c2", // string.escape
484        "#56b6c2", // string.special
485        "#e06c75", // tag
486        "#e5c07b", // type
487        "#e5c07b", // type.builtin
488        "#e06c75", // variable
489        "#e5c07b", // variable.builtin
490        "#e06c75", // variable.parameter
491        "#5c6370", // comment
492        "#e5c07b", // constructor
493        "#98c379", // embedded
494        "#c678dd", // label
495        "#d19a66", // number
496        "#c678dd", // repeat
497        "#98c379", // character
498        "#c678dd", // conditional
499        "#c678dd", // define
500        "#c678dd", // include
501        "#d19a66", // boolean
502    ],
503};
504
505/// Light theme colors (similar to One Light)
506const LIGHT_THEME_COLORS: ThemeColors = ThemeColors {
507    background: "#fafafa",
508    foreground: "#383a42",
509    highlights: &[
510        "#e45649", // attribute
511        "#986801", // constant
512        "#a626a4", // function.builtin
513        "#4078f2", // function
514        "#a626a4", // keyword
515        "#0184bc", // operator
516        "#e45649", // property
517        "#383a42", // punctuation
518        "#383a42", // punctuation.bracket
519        "#383a42", // punctuation.delimiter
520        "#50a14f", // string
521        "#0184bc", // string.escape
522        "#0184bc", // string.special
523        "#e45649", // tag
524        "#986801", // type
525        "#c18401", // type.builtin
526        "#e45649", // variable
527        "#986801", // variable.builtin
528        "#e45649", // variable.parameter
529        "#a0a1a7", // comment
530        "#986801", // constructor
531        "#50a14f", // embedded
532        "#a626a4", // label
533        "#986801", // number
534        "#a626a4", // repeat
535        "#50a14f", // character
536        "#a626a4", // conditional
537        "#a626a4", // define
538        "#a626a4", // include
539        "#986801", // boolean
540    ],
541};
542
543/// High contrast theme colors
544const HIGH_CONTRAST_THEME_COLORS: ThemeColors = ThemeColors {
545    background: "#000000",
546    foreground: "#ffffff",
547    highlights: &[
548        "#ff6b6b", // attribute
549        "#ffd93d", // constant
550        "#ffd93d", // function.builtin
551        "#6bcfff", // function
552        "#ff79c6", // keyword
553        "#8be9fd", // operator
554        "#ff6b6b", // property
555        "#ffffff", // punctuation
556        "#ffffff", // punctuation.bracket
557        "#ffffff", // punctuation.delimiter
558        "#50fa7b", // string
559        "#8be9fd", // string.escape
560        "#8be9fd", // string.special
561        "#ff6b6b", // tag
562        "#ffd93d", // type
563        "#ffd93d", // type.builtin
564        "#ff6b6b", // variable
565        "#ffd93d", // variable.builtin
566        "#ff6b6b", // variable.parameter
567        "#bfbfbf", // comment
568        "#ffd93d", // constructor
569        "#50fa7b", // embedded
570        "#ff79c6", // label
571        "#ffb86c", // number
572        "#ff79c6", // repeat
573        "#50fa7b", // character
574        "#ff79c6", // conditional
575        "#ff79c6", // define
576        "#ff79c6", // include
577        "#ffb86c", // boolean
578    ],
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("&lt;raw&gt;"));
657        assert!(html.contains("&amp;"));
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        // SQL is a recognized name but has no bundled grammar.
665        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")); // Light theme background
706    }
707
708    #[test]
709    fn test_html_escape() {
710        let escaped = html_escape("<script>alert('xss')</script>");
711        assert!(escaped.contains("&lt;"));
712        assert!(escaped.contains("&gt;"));
713        assert!(!escaped.contains("<script>"));
714    }
715
716    // ── highlight_code_blocks (rendered HTML) Tests ─────────────────────
717
718    #[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("&lt;script&gt;"), "<script>");
749        assert_eq!(html_decode("&amp;"), "&");
750    }
751}