facet_showcase/
highlighter.rs

1//! Syntax highlighting support for showcases.
2
3use syntect::easy::HighlightLines;
4use syntect::highlighting::{Style, Theme, ThemeSet};
5use syntect::html::highlighted_html_for_string;
6use syntect::parsing::{SyntaxSet, SyntaxSetBuilder};
7use syntect::util::{LinesWithEndings, as_24_bit_terminal_escaped};
8
9/// Supported languages for syntax highlighting.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum Language {
12    /// JSON format
13    Json,
14    /// YAML format
15    Yaml,
16    /// XML format
17    Xml,
18    /// KDL format (requires custom syntax definition)
19    Kdl,
20    /// Rust code (for type definitions)
21    Rust,
22}
23
24impl Language {
25    /// Returns the file extension used to look up the syntax.
26    pub fn extension(self) -> &'static str {
27        match self {
28            Language::Json => "json",
29            Language::Yaml => "yaml",
30            Language::Xml => "xml",
31            Language::Kdl => "kdl",
32            Language::Rust => "rs",
33        }
34    }
35
36    /// Returns a human-readable name for the language.
37    pub fn name(self) -> &'static str {
38        match self {
39            Language::Json => "JSON",
40            Language::Yaml => "YAML",
41            Language::Xml => "XML",
42            Language::Kdl => "KDL",
43            Language::Rust => "Rust",
44        }
45    }
46}
47
48/// Syntax highlighter using Tokyo Night theme.
49pub struct Highlighter {
50    /// Default syntax set (JSON, YAML, Rust, etc.)
51    default_ps: SyntaxSet,
52    /// Custom syntax set for KDL (built from .sublime-syntax files)
53    kdl_ps: Option<SyntaxSet>,
54    /// Tokyo Night theme
55    theme: Theme,
56}
57
58impl Default for Highlighter {
59    fn default() -> Self {
60        Self::new()
61    }
62}
63
64impl Highlighter {
65    /// Create a new highlighter with Tokyo Night theme.
66    pub fn new() -> Self {
67        let default_ps = SyntaxSet::load_defaults_newlines();
68
69        // Try to load Tokyo Night theme from the shared themes directory
70        let theme = Self::load_tokyo_night_theme();
71
72        Self {
73            default_ps,
74            kdl_ps: None,
75            theme,
76        }
77    }
78
79    /// Load Tokyo Night theme, falling back to base16-ocean.dark if not found.
80    fn load_tokyo_night_theme() -> Theme {
81        // Try common locations for the theme
82        let possible_paths = [
83            // When running from workspace root
84            "themes/TokyoNight.tmTheme",
85            // When running from a subcrate
86            "../themes/TokyoNight.tmTheme",
87            // When running from examples
88            "../../themes/TokyoNight.tmTheme",
89        ];
90
91        for path in possible_paths {
92            if let Ok(theme) = ThemeSet::get_theme(path) {
93                return theme;
94            }
95        }
96
97        // Fallback to default theme
98        let ts = ThemeSet::load_defaults();
99        ts.themes["base16-ocean.dark"].clone()
100    }
101
102    /// Add KDL syntax support from a directory containing .sublime-syntax files.
103    pub fn with_kdl_syntaxes(mut self, syntax_dir: &str) -> Self {
104        let mut builder = SyntaxSetBuilder::new();
105        builder.add_plain_text_syntax();
106        if builder.add_from_folder(syntax_dir, true).is_ok() {
107            self.kdl_ps = Some(builder.build());
108        }
109        self
110    }
111
112    /// Get a reference to the theme.
113    pub fn theme(&self) -> &Theme {
114        &self.theme
115    }
116
117    /// Highlight code and return terminal-escaped string.
118    pub fn highlight_to_terminal(&self, code: &str, lang: Language) -> String {
119        let mut output = String::new();
120
121        let (ps, syntax) = match lang {
122            Language::Kdl => {
123                if let Some(ref kdl_ps) = self.kdl_ps {
124                    // Try "KDL" first (simpler syntax), then "KDL1" (complex syntax)
125                    if let Some(syntax) = kdl_ps
126                        .find_syntax_by_name("KDL")
127                        .or_else(|| kdl_ps.find_syntax_by_name("KDL1"))
128                    {
129                        (kdl_ps, syntax)
130                    } else {
131                        // Fallback to plain text
132                        return self.plain_text_with_indent(code);
133                    }
134                } else {
135                    return self.plain_text_with_indent(code);
136                }
137            }
138            _ => {
139                let syntax = self
140                    .default_ps
141                    .find_syntax_by_extension(lang.extension())
142                    .unwrap_or_else(|| self.default_ps.find_syntax_plain_text());
143                (&self.default_ps, syntax)
144            }
145        };
146
147        let mut h = HighlightLines::new(syntax, &self.theme);
148        for line in LinesWithEndings::from(code) {
149            let ranges: Vec<(Style, &str)> = h.highlight_line(line, ps).unwrap_or_default();
150            let escaped = as_24_bit_terminal_escaped(&ranges[..], false);
151            output.push_str("    ");
152            output.push_str(&escaped);
153        }
154        output.push_str("\x1b[0m"); // Reset terminal colors
155
156        // Ensure there's a trailing newline
157        if !output.ends_with('\n') {
158            output.push('\n');
159        }
160
161        output
162    }
163
164    /// Highlight code with line numbers for terminal output.
165    pub fn highlight_to_terminal_with_line_numbers(&self, code: &str, lang: Language) -> String {
166        use owo_colors::OwoColorize;
167
168        let mut output = String::new();
169
170        let (ps, syntax) = match lang {
171            Language::Kdl => {
172                if let Some(ref kdl_ps) = self.kdl_ps {
173                    if let Some(syntax) = kdl_ps
174                        .find_syntax_by_name("KDL")
175                        .or_else(|| kdl_ps.find_syntax_by_name("KDL1"))
176                    {
177                        (kdl_ps, syntax)
178                    } else {
179                        return self.plain_text_with_line_numbers(code);
180                    }
181                } else {
182                    return self.plain_text_with_line_numbers(code);
183                }
184            }
185            _ => {
186                let syntax = self
187                    .default_ps
188                    .find_syntax_by_extension(lang.extension())
189                    .unwrap_or_else(|| self.default_ps.find_syntax_plain_text());
190                (&self.default_ps, syntax)
191            }
192        };
193
194        let mut h = HighlightLines::new(syntax, &self.theme);
195        for (i, line) in code.lines().enumerate() {
196            let line_with_newline = format!("{line}\n");
197            let ranges: Vec<(Style, &str)> =
198                h.highlight_line(&line_with_newline, ps).unwrap_or_default();
199            let escaped = as_24_bit_terminal_escaped(&ranges[..], false);
200
201            output.push_str(&format!(
202                "{} {} {}",
203                format!("{:3}", i + 1).dimmed(),
204                "│".dimmed(),
205                escaped
206            ));
207        }
208        output.push_str("\x1b[0m"); // Reset terminal colors
209
210        output
211    }
212
213    /// Build a SyntectHighlighter for miette error rendering.
214    pub fn build_miette_highlighter(
215        &self,
216        lang: Language,
217    ) -> miette::highlighters::SyntectHighlighter {
218        let (syntax_set, _) = match lang {
219            Language::Kdl => {
220                if let Some(ref kdl_ps) = self.kdl_ps {
221                    (kdl_ps.clone(), ())
222                } else {
223                    (self.default_ps.clone(), ())
224                }
225            }
226            _ => (self.default_ps.clone(), ()),
227        };
228
229        miette::highlighters::SyntectHighlighter::new(syntax_set, self.theme.clone(), false)
230    }
231
232    /// Highlight code and return HTML with inline styles.
233    pub fn highlight_to_html(&self, code: &str, lang: Language) -> String {
234        let (ps, syntax) = match lang {
235            Language::Kdl => {
236                if let Some(ref kdl_ps) = self.kdl_ps {
237                    if let Some(syntax) = kdl_ps
238                        .find_syntax_by_name("KDL")
239                        .or_else(|| kdl_ps.find_syntax_by_name("KDL1"))
240                    {
241                        (kdl_ps, syntax)
242                    } else {
243                        return html_escape(code);
244                    }
245                } else {
246                    return html_escape(code);
247                }
248            }
249            _ => {
250                let syntax = self
251                    .default_ps
252                    .find_syntax_by_extension(lang.extension())
253                    .unwrap_or_else(|| self.default_ps.find_syntax_plain_text());
254                (&self.default_ps, syntax)
255            }
256        };
257
258        // Use highlighted_html_for_string which produces inline styles
259        highlighted_html_for_string(code, ps, syntax, &self.theme)
260            .unwrap_or_else(|_| html_escape(code))
261    }
262
263    fn plain_text_with_indent(&self, code: &str) -> String {
264        let mut output = String::new();
265        for line in code.lines() {
266            output.push_str("    ");
267            output.push_str(line);
268            output.push('\n');
269        }
270        output
271    }
272
273    fn plain_text_with_line_numbers(&self, code: &str) -> String {
274        use owo_colors::OwoColorize;
275
276        let mut output = String::new();
277        for (i, line) in code.lines().enumerate() {
278            output.push_str(&format!(
279                "{} {} {}\n",
280                format!("{:3}", i + 1).dimmed(),
281                "│".dimmed(),
282                line
283            ));
284        }
285        output
286    }
287}
288
289/// Escape HTML special characters.
290pub fn html_escape(s: &str) -> String {
291    s.replace('&', "&amp;")
292        .replace('<', "&lt;")
293        .replace('>', "&gt;")
294        .replace('"', "&quot;")
295}
296
297/// Convert ANSI escape codes to HTML spans with inline styles.
298/// Uses non-breaking spaces to preserve alignment in monospace output.
299pub fn ansi_to_html(input: &str) -> String {
300    let mut output = String::new();
301    let mut chars = input.chars().peekable();
302    let mut in_span = false;
303
304    while let Some(c) = chars.next() {
305        if c == '\x1b' && chars.peek() == Some(&'[') {
306            chars.next(); // consume '['
307
308            // Parse the escape sequence
309            let mut seq = String::new();
310            while let Some(&ch) = chars.peek() {
311                if ch.is_ascii_digit() || ch == ';' {
312                    seq.push(chars.next().unwrap());
313                } else {
314                    break;
315                }
316            }
317
318            // Consume the final character (usually 'm')
319            let final_char = chars.next();
320
321            if final_char == Some('m') {
322                // Close any existing span
323                if in_span {
324                    output.push_str("</span>");
325                    in_span = false;
326                }
327
328                // Parse the style
329                if let Some(style) = parse_ansi_style(&seq)
330                    && !style.is_empty()
331                {
332                    output.push_str(&format!("<span style=\"{style}\">"));
333                    in_span = true;
334                }
335            }
336        } else if c == '<' {
337            output.push_str("&lt;");
338        } else if c == '>' {
339            output.push_str("&gt;");
340        } else if c == '&' {
341            output.push_str("&amp;");
342        } else if c == '`' {
343            // Escape backticks to prevent markdown interpretation
344            output.push_str("&#96;");
345        } else if c == ' ' {
346            // Use non-breaking space to preserve alignment
347            output.push('\u{00A0}');
348        } else {
349            output.push(c);
350        }
351    }
352
353    if in_span {
354        output.push_str("</span>");
355    }
356
357    output
358}
359
360/// Parse ANSI style codes and return CSS style string.
361fn parse_ansi_style(seq: &str) -> Option<String> {
362    if seq.is_empty() || seq == "0" {
363        return Some(String::new()); // Reset
364    }
365
366    let parts: Vec<&str> = seq.split(';').collect();
367    let mut styles = Vec::new();
368
369    let mut i = 0;
370    while i < parts.len() {
371        match parts[i] {
372            "0" => return Some(String::new()), // Reset
373            "1" => styles.push("font-weight:bold".to_string()),
374            "2" => styles.push("opacity:0.7".to_string()), // Dim
375            "3" => styles.push("font-style:italic".to_string()),
376            "4" => styles.push("text-decoration:underline".to_string()),
377            "30" => styles.push("color:#000".to_string()),
378            "31" => styles.push("color:#e06c75".to_string()), // Red
379            "32" => styles.push("color:#98c379".to_string()), // Green
380            "33" => styles.push("color:#e5c07b".to_string()), // Yellow
381            "34" => styles.push("color:#61afef".to_string()), // Blue
382            "35" => styles.push("color:#c678dd".to_string()), // Magenta
383            "36" => styles.push("color:#56b6c2".to_string()), // Cyan
384            "37" => styles.push("color:#abb2bf".to_string()), // White
385            "38" => {
386                // Extended color
387                if i + 1 < parts.len() && parts[i + 1] == "2" {
388                    // 24-bit RGB
389                    if i + 4 < parts.len() {
390                        let r = parts[i + 2];
391                        let g = parts[i + 3];
392                        let b = parts[i + 4];
393                        styles.push(format!("color:rgb({r},{g},{b})"));
394                        i += 4;
395                    }
396                } else if i + 1 < parts.len() && parts[i + 1] == "5" {
397                    // 256-color palette
398                    if i + 2 < parts.len() {
399                        if let Ok(n) = parts[i + 2].parse::<u8>() {
400                            let color = ansi_256_to_rgb(n);
401                            styles.push(format!("color:{color}"));
402                        }
403                        i += 2;
404                    }
405                }
406            }
407            "90" => styles.push("color:#5c6370".to_string()), // Bright black (gray)
408            "91" => styles.push("color:#e06c75".to_string()), // Bright red
409            "92" => styles.push("color:#98c379".to_string()), // Bright green
410            "93" => styles.push("color:#e5c07b".to_string()), // Bright yellow
411            "94" => styles.push("color:#61afef".to_string()), // Bright blue
412            "95" => styles.push("color:#c678dd".to_string()), // Bright magenta
413            "96" => styles.push("color:#56b6c2".to_string()), // Bright cyan
414            "97" => styles.push("color:#fff".to_string()),    // Bright white
415            _ => {}
416        }
417        i += 1;
418    }
419
420    Some(styles.join(";"))
421}
422
423/// Convert ANSI 256-color palette index to hex color.
424fn ansi_256_to_rgb(n: u8) -> &'static str {
425    match n {
426        // Standard colors (0-7)
427        0 => "#000000",
428        1 => "#800000",
429        2 => "#008000",
430        3 => "#808000",
431        4 => "#000080",
432        5 => "#800080",
433        6 => "#008080",
434        7 => "#c0c0c0",
435        // High-intensity colors (8-15)
436        8 => "#808080",
437        9 => "#e06c75",  // Bright red (used by rustc for errors)
438        10 => "#98c379", // Bright green
439        11 => "#e5c07b", // Bright yellow
440        12 => "#61afef", // Bright blue (used by rustc for line numbers)
441        13 => "#c678dd", // Bright magenta
442        14 => "#56b6c2", // Bright cyan
443        15 => "#ffffff",
444        // 216-color cube (16-231)
445        16..=231 => {
446            // This is a cube where each RGB component goes 0, 95, 135, 175, 215, 255
447            // For simplicity, return a reasonable approximation
448            "#888888"
449        }
450        // Grayscale (232-255)
451        232..=255 => {
452            // Grayscale from dark to light
453            "#888888"
454        }
455    }
456}
457
458#[cfg(test)]
459mod tests {
460    use super::Language;
461
462    #[test]
463    fn xml_language_metadata_is_exposed() {
464        assert_eq!(Language::Xml.name(), "XML");
465        assert_eq!(Language::Xml.extension(), "xml");
466    }
467}