facet_showcase/
highlighter.rs

1//! Syntax highlighting support for showcases.
2
3use std::cell::RefCell;
4
5use arborium::theme::Theme;
6use arborium::{AnsiHighlighter, Highlighter as ArboriumHighlighter};
7use miette_arborium::MietteHighlighter;
8use owo_colors::OwoColorize;
9
10const INDENT: &str = "    ";
11
12/// Supported languages for syntax highlighting.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum Language {
15    /// JSON format
16    Json,
17    /// YAML format
18    Yaml,
19    /// XML format
20    Xml,
21    /// KDL format
22    Kdl,
23    /// Rust code (for type definitions)
24    Rust,
25}
26
27impl Language {
28    /// Returns the file extension used to look up the syntax.
29    pub fn extension(self) -> &'static str {
30        match self {
31            Language::Json => "json",
32            Language::Yaml => "yaml",
33            Language::Xml => "xml",
34            Language::Kdl => "kdl",
35            Language::Rust => "rs",
36        }
37    }
38
39    /// Returns a human-readable name for the language.
40    pub fn name(self) -> &'static str {
41        match self {
42            Language::Json => "JSON",
43            Language::Yaml => "YAML",
44            Language::Xml => "XML",
45            Language::Kdl => "KDL",
46            Language::Rust => "Rust",
47        }
48    }
49
50    fn arborium_name(self) -> &'static str {
51        match self {
52            Language::Json => "json",
53            Language::Yaml => "yaml",
54            Language::Xml => "xml",
55            Language::Kdl => "kdl",
56            Language::Rust => "rust",
57        }
58    }
59}
60
61/// Syntax highlighter using Tokyo Night theme powered by arborium.
62pub struct Highlighter {
63    html_highlighter: RefCell<ArboriumHighlighter>,
64    ansi_highlighter: RefCell<AnsiHighlighter>,
65    theme: Theme,
66}
67
68impl Default for Highlighter {
69    fn default() -> Self {
70        Self::new()
71    }
72}
73
74impl Highlighter {
75    /// Create a new highlighter with the Tokyo Night theme.
76    pub fn new() -> Self {
77        let theme = arborium::theme::builtin::tokyo_night().clone();
78        Self {
79            html_highlighter: RefCell::new(ArboriumHighlighter::new()),
80            ansi_highlighter: RefCell::new(AnsiHighlighter::new(theme.clone())),
81            theme,
82        }
83    }
84
85    /// KDL grammars ship with arborium, so this is a no-op retained for API compatibility.
86    pub fn with_kdl_syntaxes(self, _syntax_dir: &str) -> Self {
87        self
88    }
89
90    /// Get a reference to the theme.
91    pub fn theme(&self) -> &Theme {
92        &self.theme
93    }
94
95    /// Highlight code and return terminal-escaped string.
96    pub fn highlight_to_terminal(&self, code: &str, lang: Language) -> String {
97        let mut hl = self.ansi_highlighter.borrow_mut();
98        match hl.highlight(lang.arborium_name(), code) {
99            Ok(output) => {
100                // Add indentation to each line
101                let mut result = String::new();
102                for line in output.lines() {
103                    result.push_str(INDENT);
104                    result.push_str(line);
105                    result.push('\n');
106                }
107                result
108            }
109            Err(_) => self.plain_text_with_indent(code),
110        }
111    }
112
113    /// Highlight code with line numbers for terminal output.
114    pub fn highlight_to_terminal_with_line_numbers(&self, code: &str, lang: Language) -> String {
115        let mut hl = self.ansi_highlighter.borrow_mut();
116        match hl.highlight(lang.arborium_name(), code) {
117            Ok(output) => {
118                let mut result = String::new();
119                for (i, line) in output.lines().enumerate() {
120                    result.push_str(&format!(
121                        "{} {} {}\n",
122                        format!("{:3}", i + 1).dimmed(),
123                        "│".dimmed(),
124                        line
125                    ));
126                }
127                result
128            }
129            Err(_) => self.plain_text_with_line_numbers(code),
130        }
131    }
132
133    /// Build a miette highlighter using arborium.
134    pub fn build_miette_highlighter(&self, _lang: Language) -> MietteHighlighter {
135        MietteHighlighter::new()
136    }
137
138    /// Highlight code and return HTML with inline styles.
139    pub fn highlight_to_html(&self, code: &str, lang: Language) -> String {
140        let mut hl = self.html_highlighter.borrow_mut();
141        match hl.highlight(lang.arborium_name(), code) {
142            Ok(html) => wrap_with_pre(html, &self.theme),
143            Err(_) => wrap_plain_text_html(code, &self.theme),
144        }
145    }
146
147    fn plain_text_with_indent(&self, code: &str) -> String {
148        let mut output = String::new();
149        for line in code.lines() {
150            output.push_str(INDENT);
151            output.push_str(line);
152            output.push('\n');
153        }
154        output
155    }
156
157    fn plain_text_with_line_numbers(&self, code: &str) -> String {
158        let mut output = String::new();
159        for (i, line) in code.lines().enumerate() {
160            output.push_str(&format!(
161                "{} {} {}\n",
162                format!("{:3}", i + 1).dimmed(),
163                "│".dimmed(),
164                line
165            ));
166        }
167        output
168    }
169}
170
171/// Escape HTML special characters.
172pub fn html_escape(s: &str) -> String {
173    s.replace('&', "&amp;")
174        .replace('<', "&lt;")
175        .replace('>', "&gt;")
176        .replace('"', "&quot;")
177}
178
179/// Convert ANSI escape codes to HTML spans with inline styles.
180/// Uses non-breaking spaces to preserve alignment in monospace output.
181pub fn ansi_to_html(input: &str) -> String {
182    let mut output = String::new();
183    let mut chars = input.chars().peekable();
184    let mut in_span = false;
185
186    while let Some(c) = chars.next() {
187        if c == '\x1b' && chars.peek() == Some(&'[') {
188            chars.next(); // consume '['
189
190            // Parse the escape sequence
191            let mut seq = String::new();
192            while let Some(&ch) = chars.peek() {
193                if ch.is_ascii_digit() || ch == ';' {
194                    seq.push(chars.next().unwrap());
195                } else {
196                    break;
197                }
198            }
199
200            // Consume the final character (usually 'm')
201            let final_char = chars.next();
202
203            if final_char == Some('m') {
204                // Close any existing span
205                if in_span {
206                    output.push_str("</span>");
207                    in_span = false;
208                }
209
210                // Parse the style
211                if let Some(style) = parse_ansi_style(&seq)
212                    && !style.is_empty()
213                {
214                    output.push_str(&format!("<span style=\"{style}\">"));
215                    in_span = true;
216                }
217            }
218        } else if c == '<' {
219            output.push_str("&lt;");
220        } else if c == '>' {
221            output.push_str("&gt;");
222        } else if c == '&' {
223            output.push_str("&amp;");
224        } else if c == '`' {
225            // Escape backticks to prevent markdown interpretation
226            output.push_str("&#96;");
227        } else if c == ' ' {
228            // Use non-breaking space to preserve alignment
229            output.push('\u{00A0}');
230        } else {
231            output.push(c);
232        }
233    }
234
235    if in_span {
236        output.push_str("</span>");
237    }
238
239    output
240}
241
242/// Parse ANSI style codes and return CSS style string.
243fn parse_ansi_style(seq: &str) -> Option<String> {
244    if seq.is_empty() || seq == "0" {
245        return Some(String::new()); // Reset
246    }
247
248    let parts: Vec<&str> = seq.split(';').collect();
249    let mut styles = Vec::new();
250
251    let mut i = 0;
252    while i < parts.len() {
253        match parts[i] {
254            "0" => return Some(String::new()), // Reset
255            "1" => styles.push("font-weight:bold".to_string()),
256            "2" => styles.push("opacity:0.7".to_string()), // Dim
257            "3" => styles.push("font-style:italic".to_string()),
258            "4" => styles.push("text-decoration:underline".to_string()),
259            "30" => styles.push("color:#000".to_string()),
260            "31" => styles.push("color:#e06c75".to_string()), // Red
261            "32" => styles.push("color:#98c379".to_string()), // Green
262            "33" => styles.push("color:#e5c07b".to_string()), // Yellow
263            "34" => styles.push("color:#61afef".to_string()), // Blue
264            "35" => styles.push("color:#c678dd".to_string()), // Magenta
265            "36" => styles.push("color:#56b6c2".to_string()), // Cyan
266            "37" => styles.push("color:#abb2bf".to_string()), // White
267            "38" => {
268                // Extended color
269                if i + 1 < parts.len() && parts[i + 1] == "2" {
270                    // 24-bit RGB
271                    if i + 4 < parts.len() {
272                        let r = parts[i + 2];
273                        let g = parts[i + 3];
274                        let b = parts[i + 4];
275                        styles.push(format!("color:rgb({r},{g},{b})"));
276                        i += 4;
277                    }
278                } else if i + 1 < parts.len()
279                    && parts[i + 1] == "5"
280                    && i + 2 < parts.len()
281                    && let Ok(n) = parts[i + 2].parse::<u8>()
282                {
283                    let color = ansi_256_to_rgb(n);
284                    styles.push(format!("color:{color}"));
285                    i += 2;
286                }
287            }
288            "39" => styles.push("color:inherit".to_string()),
289            "40" => styles.push("background-color:#000".to_string()),
290            "41" => styles.push("background-color:#e06c75".to_string()),
291            "42" => styles.push("background-color:#98c379".to_string()),
292            "43" => styles.push("background-color:#e5c07b".to_string()),
293            "44" => styles.push("background-color:#61afef".to_string()),
294            "45" => styles.push("background-color:#c678dd".to_string()),
295            "46" => styles.push("background-color:#56b6c2".to_string()),
296            "47" => styles.push("background-color:#abb2bf".to_string()),
297            "48" => {
298                if i + 1 < parts.len() && parts[i + 1] == "2" {
299                    if i + 4 < parts.len() {
300                        let r = parts[i + 2];
301                        let g = parts[i + 3];
302                        let b = parts[i + 4];
303                        styles.push(format!("background-color:rgb({r},{g},{b})"));
304                        i += 4;
305                    }
306                } else if i + 1 < parts.len()
307                    && parts[i + 1] == "5"
308                    && i + 2 < parts.len()
309                    && let Ok(n) = parts[i + 2].parse::<u8>()
310                {
311                    let color = ansi_256_to_rgb(n);
312                    styles.push(format!("background-color:{color}"));
313                    i += 2;
314                }
315            }
316            "49" => styles.push("background-color:transparent".to_string()),
317            "90" => styles.push("color:#5c6370".to_string()), // Bright black (dim)
318            "91" => styles.push("color:#e06c75".to_string()), // Bright red
319            "92" => styles.push("color:#98c379".to_string()),
320            "93" => styles.push("color:#e5c07b".to_string()), // Bright yellow
321            "94" => styles.push("color:#61afef".to_string()),
322            "95" => styles.push("color:#c678dd".to_string()), // Bright magenta
323            "96" => styles.push("color:#56b6c2".to_string()),
324            "97" => styles.push("color:#fff".to_string()), // Bright white
325            _ => {}
326        }
327        i += 1;
328    }
329
330    if styles.is_empty() {
331        None
332    } else {
333        Some(styles.join(";"))
334    }
335}
336
337fn ansi_256_to_rgb(n: u8) -> &'static str {
338    match n {
339        0 => "#000000",
340        1 => "#800000",
341        2 => "#008000",
342        3 => "#808000",
343        4 => "#000080",
344        5 => "#800080",
345        6 => "#008080",
346        7 => "#c0c0c0",
347        8 => "#808080",
348        9 => "#ff0000",
349        10 => "#00ff00",
350        11 => "#ffff00",
351        12 => "#0000ff",
352        13 => "#ff00ff",
353        14 => "#00ffff",
354        15 => "#ffffff",
355        _ => "#888888",
356    }
357}
358
359fn wrap_plain_text_html(code: &str, theme: &Theme) -> String {
360    wrap_with_pre(html_escape(code), theme)
361}
362
363fn wrap_with_pre(content: String, theme: &Theme) -> String {
364    let mut styles = Vec::new();
365    if let Some(bg) = theme.background {
366        styles.push(format!("background-color:{};", bg.to_hex()));
367    }
368    if let Some(fg) = theme.foreground {
369        styles.push(format!("color:{};", fg.to_hex()));
370    }
371    styles.push("padding:12px;".to_string());
372    styles.push("border-radius:8px;".to_string());
373    styles.push(
374        "font-family:var(--facet-mono, SFMono-Regular, Consolas, 'Liberation Mono', monospace);"
375            .to_string(),
376    );
377    styles.push("font-size:0.9rem;".to_string());
378    styles.push("overflow:auto;".to_string());
379    format!(
380        "<pre style=\"{}\"><code>{}</code></pre>",
381        styles.join(" "),
382        content
383    )
384}
385
386#[cfg(test)]
387mod tests {
388    use super::Language;
389
390    #[test]
391    fn xml_language_metadata_is_exposed() {
392        assert_eq!(Language::Xml.name(), "XML");
393        assert_eq!(Language::Xml.extension(), "xml");
394    }
395}