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        // `mut` is only exercised when at least one `lang-*` feature is on.
51        #[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        // The markdown block grammar injects `markdown-inline` for inline
187        // content; register the inline configuration for the injection
188        // callback below.
189        #[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        // SQL is a known language name but has no bundled grammar.
208        // Note: SQL highlighting requires an external grammar; falls back to
209        // plain output.
210
211        debug!(
212            "Initialized {} syntax highlight configurations",
213            configs.len()
214        );
215        configs
216    }
217
218    /// Resolve a highlight configuration for an injected language name.
219    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    /// Highlight code and return HTML
228    ///
229    /// # Errors
230    ///
231    /// Returns [`Error::UnsupportedLanguage`] when the language name is not
232    /// recognized or its `lang-*` feature is disabled.
233    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    /// Highlight code with a known language
241    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    /// Highlight code, falling back to a plain (escaped) `<pre><code>` block
272    /// when the language is unknown or unsupported.
273    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    /// Get CSS class for a highlight
279    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    /// Get the current theme
289    pub fn theme(&self) -> SyntaxTheme {
290        self.theme
291    }
292
293    /// Set the theme
294    pub fn set_theme(&mut self, theme: SyntaxTheme) {
295        self.theme = theme;
296    }
297
298    /// Check if a language is supported
299    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    /// Get list of supported languages
306    pub fn supported_languages(&self) -> Vec<&'static str> {
307        self.configs.keys().map(|lang| lang.as_str()).collect()
308    }
309
310    /// Generate CSS stylesheet for the current theme
311    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, // Default to dark for custom
317        };
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// ============================================================================
352// Rendered-HTML code block highlighting
353// ============================================================================
354
355/// INVARIANT: every pattern passed here is a compile-time constant that was
356/// validated at development time. `Regex::new` can only fail on invalid
357/// syntax, so a panic from `expect` indicates a bug in this crate's own
358/// patterns — never a recoverable runtime condition.
359#[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
373/// Highlight all `<pre><code class="language-...">` blocks in rendered HTML.
374///
375/// Each recognized block is replaced with
376/// `<div class="code-block-wrapper"><pre class="syntax-highlight" data-language="..." data-theme="...">…</pre>`
377/// plus a copy-to-clipboard button, matching the docs-site rendering pipeline.
378/// The theme is stamped on the output as `data-theme` so downstream CSS can
379/// restyle per theme. Blocks with unknown or unsupported languages are left
380/// untouched.
381pub 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: &regex::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
416/// Stable lowercase slug for a theme (used in the `data-theme` attribute).
417fn 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("&lt;", "<")
435        .replace("&gt;", ">")
436        .replace("&amp;", "&")
437        .replace("&quot;", "\"")
438        .replace("&#39;", "'")
439        .replace("&#x27;", "'")
440}
441
442/// HTML escape helper
443fn html_escape(s: &str) -> String {
444    s.replace('&', "&amp;")
445        .replace('<', "&lt;")
446        .replace('>', "&gt;")
447        .replace('"', "&quot;")
448        .replace('\'', "&#39;")
449}
450
451/// Theme highlight names (standard tree-sitter highlight names)
452const 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
485/// Theme color definitions
486struct ThemeColors {
487    background: &'static str,
488    foreground: &'static str,
489    highlights: &'static [&'static str],
490}
491
492/// Dark theme colors (similar to One Dark)
493const DARK_THEME_COLORS: ThemeColors = ThemeColors {
494    background: "#282c34",
495    foreground: "#abb2bf",
496    highlights: &[
497        "#e06c75", // attribute
498        "#e5c07b", // constant
499        "#e5c07b", // function.builtin
500        "#61afef", // function
501        "#c678dd", // keyword
502        "#56b6c2", // operator
503        "#e06c75", // property
504        "#abb2bf", // punctuation
505        "#abb2bf", // punctuation.bracket
506        "#abb2bf", // punctuation.delimiter
507        "#98c379", // string
508        "#56b6c2", // string.escape
509        "#56b6c2", // string.special
510        "#e06c75", // tag
511        "#e5c07b", // type
512        "#e5c07b", // type.builtin
513        "#e06c75", // variable
514        "#e5c07b", // variable.builtin
515        "#e06c75", // variable.parameter
516        "#5c6370", // comment
517        "#e5c07b", // constructor
518        "#98c379", // embedded
519        "#c678dd", // label
520        "#d19a66", // number
521        "#c678dd", // repeat
522        "#98c379", // character
523        "#c678dd", // conditional
524        "#c678dd", // define
525        "#c678dd", // include
526        "#d19a66", // boolean
527    ],
528};
529
530/// Light theme colors (similar to One Light)
531const LIGHT_THEME_COLORS: ThemeColors = ThemeColors {
532    background: "#fafafa",
533    foreground: "#383a42",
534    highlights: &[
535        "#e45649", // attribute
536        "#986801", // constant
537        "#a626a4", // function.builtin
538        "#4078f2", // function
539        "#a626a4", // keyword
540        "#0184bc", // operator
541        "#e45649", // property
542        "#383a42", // punctuation
543        "#383a42", // punctuation.bracket
544        "#383a42", // punctuation.delimiter
545        "#50a14f", // string
546        "#0184bc", // string.escape
547        "#0184bc", // string.special
548        "#e45649", // tag
549        "#986801", // type
550        "#c18401", // type.builtin
551        "#e45649", // variable
552        "#986801", // variable.builtin
553        "#e45649", // variable.parameter
554        "#a0a1a7", // comment
555        "#986801", // constructor
556        "#50a14f", // embedded
557        "#a626a4", // label
558        "#986801", // number
559        "#a626a4", // repeat
560        "#50a14f", // character
561        "#a626a4", // conditional
562        "#a626a4", // define
563        "#a626a4", // include
564        "#986801", // boolean
565    ],
566};
567
568/// High contrast theme colors
569const HIGH_CONTRAST_THEME_COLORS: ThemeColors = ThemeColors {
570    background: "#000000",
571    foreground: "#ffffff",
572    highlights: &[
573        "#ff6b6b", // attribute
574        "#ffd93d", // constant
575        "#ffd93d", // function.builtin
576        "#6bcfff", // function
577        "#ff79c6", // keyword
578        "#8be9fd", // operator
579        "#ff6b6b", // property
580        "#ffffff", // punctuation
581        "#ffffff", // punctuation.bracket
582        "#ffffff", // punctuation.delimiter
583        "#50fa7b", // string
584        "#8be9fd", // string.escape
585        "#8be9fd", // string.special
586        "#ff6b6b", // tag
587        "#ffd93d", // type
588        "#ffd93d", // type.builtin
589        "#ff6b6b", // variable
590        "#ffd93d", // variable.builtin
591        "#ff6b6b", // variable.parameter
592        "#bfbfbf", // comment
593        "#ffd93d", // constructor
594        "#50fa7b", // embedded
595        "#ff79c6", // label
596        "#ffb86c", // number
597        "#ff79c6", // repeat
598        "#50fa7b", // character
599        "#ff79c6", // conditional
600        "#ff79c6", // define
601        "#ff79c6", // include
602        "#ffb86c", // boolean
603    ],
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("&lt;raw&gt;"));
685        assert!(html.contains("&amp;"));
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        // SQL is a recognized name but has no bundled grammar.
693        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")); // Light theme background
734    }
735
736    #[test]
737    fn test_html_escape() {
738        let escaped = html_escape("<script>alert('xss')</script>");
739        assert!(escaped.contains("&lt;"));
740        assert!(escaped.contains("&gt;"));
741        assert!(!escaped.contains("<script>"));
742    }
743
744    // ── highlight_code_blocks (rendered HTML) Tests ─────────────────────
745
746    #[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("&lt;script&gt;"), "<script>");
778        assert_eq!(html_decode("&amp;"), "&");
779    }
780}