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
353/// INVARIANT: every pattern passed here is a compile-time constant that was
354/// validated at development time. `Regex::new` can only fail on invalid
355/// syntax, so a panic from `expect` indicates a bug in this crate's own
356/// patterns — never a recoverable runtime condition.
357#[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
371/// Highlight all `<pre><code class="language-...">` blocks in rendered HTML.
372///
373/// Each recognized block is replaced with
374/// `<div class="code-block-wrapper"><pre class="syntax-highlight" data-language="...">…</pre>`
375/// plus a copy-to-clipboard button, matching the docs-site rendering pipeline.
376/// Blocks with unknown or unsupported languages are left untouched.
377pub 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: &regex::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("&lt;", "<")
418        .replace("&gt;", ">")
419        .replace("&amp;", "&")
420        .replace("&quot;", "\"")
421        .replace("&#39;", "'")
422        .replace("&#x27;", "'")
423}
424
425/// HTML escape helper
426fn html_escape(s: &str) -> String {
427    s.replace('&', "&amp;")
428        .replace('<', "&lt;")
429        .replace('>', "&gt;")
430        .replace('"', "&quot;")
431        .replace('\'', "&#39;")
432}
433
434/// Theme highlight names (standard tree-sitter highlight names)
435const 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
468/// Theme color definitions
469struct ThemeColors {
470    background: &'static str,
471    foreground: &'static str,
472    highlights: &'static [&'static str],
473}
474
475/// Dark theme colors (similar to One Dark)
476const DARK_THEME_COLORS: ThemeColors = ThemeColors {
477    background: "#282c34",
478    foreground: "#abb2bf",
479    highlights: &[
480        "#e06c75", // attribute
481        "#e5c07b", // constant
482        "#e5c07b", // function.builtin
483        "#61afef", // function
484        "#c678dd", // keyword
485        "#56b6c2", // operator
486        "#e06c75", // property
487        "#abb2bf", // punctuation
488        "#abb2bf", // punctuation.bracket
489        "#abb2bf", // punctuation.delimiter
490        "#98c379", // string
491        "#56b6c2", // string.escape
492        "#56b6c2", // string.special
493        "#e06c75", // tag
494        "#e5c07b", // type
495        "#e5c07b", // type.builtin
496        "#e06c75", // variable
497        "#e5c07b", // variable.builtin
498        "#e06c75", // variable.parameter
499        "#5c6370", // comment
500        "#e5c07b", // constructor
501        "#98c379", // embedded
502        "#c678dd", // label
503        "#d19a66", // number
504        "#c678dd", // repeat
505        "#98c379", // character
506        "#c678dd", // conditional
507        "#c678dd", // define
508        "#c678dd", // include
509        "#d19a66", // boolean
510    ],
511};
512
513/// Light theme colors (similar to One Light)
514const LIGHT_THEME_COLORS: ThemeColors = ThemeColors {
515    background: "#fafafa",
516    foreground: "#383a42",
517    highlights: &[
518        "#e45649", // attribute
519        "#986801", // constant
520        "#a626a4", // function.builtin
521        "#4078f2", // function
522        "#a626a4", // keyword
523        "#0184bc", // operator
524        "#e45649", // property
525        "#383a42", // punctuation
526        "#383a42", // punctuation.bracket
527        "#383a42", // punctuation.delimiter
528        "#50a14f", // string
529        "#0184bc", // string.escape
530        "#0184bc", // string.special
531        "#e45649", // tag
532        "#986801", // type
533        "#c18401", // type.builtin
534        "#e45649", // variable
535        "#986801", // variable.builtin
536        "#e45649", // variable.parameter
537        "#a0a1a7", // comment
538        "#986801", // constructor
539        "#50a14f", // embedded
540        "#a626a4", // label
541        "#986801", // number
542        "#a626a4", // repeat
543        "#50a14f", // character
544        "#a626a4", // conditional
545        "#a626a4", // define
546        "#a626a4", // include
547        "#986801", // boolean
548    ],
549};
550
551/// High contrast theme colors
552const HIGH_CONTRAST_THEME_COLORS: ThemeColors = ThemeColors {
553    background: "#000000",
554    foreground: "#ffffff",
555    highlights: &[
556        "#ff6b6b", // attribute
557        "#ffd93d", // constant
558        "#ffd93d", // function.builtin
559        "#6bcfff", // function
560        "#ff79c6", // keyword
561        "#8be9fd", // operator
562        "#ff6b6b", // property
563        "#ffffff", // punctuation
564        "#ffffff", // punctuation.bracket
565        "#ffffff", // punctuation.delimiter
566        "#50fa7b", // string
567        "#8be9fd", // string.escape
568        "#8be9fd", // string.special
569        "#ff6b6b", // tag
570        "#ffd93d", // type
571        "#ffd93d", // type.builtin
572        "#ff6b6b", // variable
573        "#ffd93d", // variable.builtin
574        "#ff6b6b", // variable.parameter
575        "#bfbfbf", // comment
576        "#ffd93d", // constructor
577        "#50fa7b", // embedded
578        "#ff79c6", // label
579        "#ffb86c", // number
580        "#ff79c6", // repeat
581        "#50fa7b", // character
582        "#ff79c6", // conditional
583        "#ff79c6", // define
584        "#ff79c6", // include
585        "#ffb86c", // boolean
586    ],
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("&lt;raw&gt;"));
666        assert!(html.contains("&amp;"));
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        // SQL is a recognized name but has no bundled grammar.
674        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")); // Light theme background
715    }
716
717    #[test]
718    fn test_html_escape() {
719        let escaped = html_escape("<script>alert('xss')</script>");
720        assert!(escaped.contains("&lt;"));
721        assert!(escaped.contains("&gt;"));
722        assert!(!escaped.contains("<script>"));
723    }
724
725    // ── highlight_code_blocks (rendered HTML) Tests ─────────────────────
726
727    #[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("&lt;script&gt;"), "<script>");
758        assert_eq!(html_decode("&amp;"), "&");
759    }
760}